diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab6a6ce..daa0184 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- See your contacts' birthdays and anniversaries in your calendar. A new,
+ **optional** feature (Settings → Contact special dates) mirrors your contacts'
+ birthdays, anniversaries and other dates into local "Birthdays",
+ "Anniversaries" and "Other dates" calendars that stay in sync as your contacts
+ change. Each is a normal local calendar, so you set its colour, visibility and
+ reminders the usual way — new birthdays even start with a reminder a week before
+ *and* on the day. The title format is yours to customise (`{name}`, `{year}`).
+ This is the first feature to use the contacts permission: it is requested only
+ when you turn the feature on, everything stays on your device (Calendula has no
+ internet access), and your contacts are only ever read, never changed. Thanks
+ to @moonj for the suggestion ([#15]).
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
@@ -764,6 +775,7 @@ automatically, with zero telemetry and no internet permission.
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
+[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 29f9c5e..8ad7750 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,15 @@
+
+
Alle Termine, schön im Blick
- Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.
+ Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App zu Beginn nicht — und nichts verlässt je dein Gerät.
Kalender-Zugriff erlauben
Kalender-Zugriff abgelehnt
Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.
@@ -387,4 +387,38 @@
Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.
Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n
_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_
+
+
+ Geburtstage
+ Jahrestage
+ Besondere Tage
+ {name}s Geburtstag ({year})
+ {name}s Jahrestag ({year})
+ {name}
+ Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.
+ Geburtstage & Jahrestage aus Kontakten
+ Besondere Kontakttage
+ Kontakttage anzeigen
+ Spiegelt Geburtstage und weitere Daten deiner Kontakte in lokale Kalender. Liest Kontakte nur auf diesem Gerät — nichts wird hochgeladen, und deine Kontakte werden nie verändert.
+ Geburtstage
+ Jahrestage
+ Weitere Tage
+ Titelformat
+ Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt).
+ Erinnerungen
+ Jahr anzeigen
+ {year} in Titeln anzeigen, wenn es bekannt ist
+ Jetzt synchronisieren
+ Noch nicht synchronisiert
+ Zuletzt synchronisiert %1$s
+ Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest.
+ In Besondere Kontakttage festgelegt
+ Pausiert
+ Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.
+ Zugriff erlauben
+ Kontakttage deaktivieren?
+ Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.
+ Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.
+ Deaktivieren
+ Speichern
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2995a32..9c9e8df 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -14,7 +14,7 @@
See all your events, beautifully
- Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.
+ Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.
Grant calendar access
Calendar access denied
Calendula cannot show events without calendar access. You can grant it again in the system settings.
@@ -68,6 +68,8 @@
Close
Save
Add title
+
+ Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.
Starts
Ends
Ends before it starts
@@ -360,6 +362,35 @@
Quick-switch button and menu order
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 {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).
+ Reminders
+ Show year
+ Include {year} in titles when it is known
+ Sync now
+ Not synced yet
+
+ Last synced %1$s
+ Set each calendar\'s colour and visibility in Calendars settings.
+ Set in Contact special dates
+ 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
@@ -478,4 +509,13 @@
_(The report was too long for this link — paste it from your clipboard here.)_
https://codeberg.org/jlmakiola/calendula/issues/new
https://codeberg.org/jlmakiola/calendula/issues/new/choose
+
+
+ Birthdays
+ Anniversaries
+ Special dates
+ {name}\'s birthday ({year})
+ {name}\'s anniversary ({year})
+ {name}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt
index 51083a5..7ebb69b 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt
@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
}
+ @Test
+ fun `next yearly occurrence wraps to next year once this year's date has passed`() {
+ val today = LocalDate.of(2026, 8, 1)
+ assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14))
+ assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25))
+ }
+
+ @Test
+ fun `next yearly occurrence returns today when the date is today`() {
+ val today = LocalDate.of(2026, 3, 10)
+ assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today)
+ }
+
+ @Test
+ fun `next yearly occurrence skips non-leap years for a Feb-29 date`() {
+ assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1)))
+ .isEqualTo(LocalDate.of(2028, 2, 29))
+ }
+
+ @Test
+ fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() {
+ // A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then,
+ // UTC+1). Sampling the offset there skews a modern CEST occurrence by an
+ // hour; sampling at the upcoming occurrence fires at the intended time.
+ val anchor1972 = LocalDate.of(1972, 7, 15)
+ val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST
+ val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm)
+ val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm)
+
+ val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence)
+ .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
+ val fireFromNext = actualFire(rawFromNext, nextOccurrence)
+ .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
+ assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h
+ assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
+ }
+
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
index 900c9bc..f5a023a 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
@@ -1,12 +1,15 @@
package de.jeanlucmakiola.calendula.data.calendar
+import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
+import java.time.ZoneId
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteCalendar(id: Long) {
writeError?.let { throw it }
deletedCalendarIds += id
+ managedCalendarRows.removeAll { it.id == id }
+ managedEvents.remove(id)
}
/** All-day reminder fire-time minute-of-day passed into the last write. */
@@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId
+ managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
+ }
+
+ // --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
+
+ var nextManagedEventId: Long = 1_000L
+ private var managedCalendarSeq: Long = 700L
+ private val managedCalendarRows = mutableListOf()
+ private val managedEvents = mutableMapOf>()
+
+ data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
+ val createdManagedCalendars = mutableListOf()
+ val insertedManagedEvents = mutableListOf>()
+ val updatedManagedFields = mutableListOf>>()
+
+ /** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
+ fun presetManagedCalendar(id: Long, type: SpecialDateType) {
+ managedCalendarRows += ManagedCalendarRow(id, type)
+ managedEvents.getOrPut(id) { mutableListOf() }
+ }
+
+ override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
+ writeError?.let { throw it }
+ createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
+ val id = managedCalendarSeq++
+ managedCalendarRows += ManagedCalendarRow(id, type)
+ managedEvents.getOrPut(id) { mutableListOf() }
+ return id
+ }
+
+ override fun findManagedCalendars(): List = managedCalendarRows.toList()
+
+ override fun queryManagedEvents(calendarId: Long): List =
+ managedEvents[calendarId]?.toList() ?: emptyList()
+
+ override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
+ writeError?.let { throw it }
+ insertedManagedEvents += form to uid
+ allDayReminderTimes += allDayReminderTimeMinutes
+ val id = nextManagedEventId++
+ val times = form.toWriteTimes(ZoneId.systemDefault())
+ managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
+ ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
+ return id
+ }
+
+ /** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
+ val appliedManagedReminders = mutableListOf>>()
+
+ override fun applyManagedCalendarReminders(
+ calendarId: Long,
+ allDayReminderTimeMinutes: Int,
+ minutes: List,
+ ) {
+ writeError?.let { throw it }
+ appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
+ }
+
+ override fun updateManagedFields(eventId: Long, values: Map) {
+ writeError?.let { throw it }
+ if (values.isEmpty()) return
+ updatedManagedFields += eventId to values
+ managedEvents.values.forEach { rows ->
+ val idx = rows.indexOfFirst { it.eventId == eventId }
+ if (idx >= 0) {
+ var row = rows[idx]
+ (values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
+ (values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
+ if (values.containsKey(CalendarContract.Events.RRULE)) {
+ row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
+ }
+ rows[idx] = row
+ }
+ }
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt
new file mode 100644
index 0000000..dbfcff8
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt
@@ -0,0 +1,226 @@
+package de.jeanlucmakiola.calendula.data.contacts
+
+import android.provider.CalendarContract
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
+import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
+import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
+import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Path
+
+class SpecialDatesSyncEngineTest {
+
+ private class FakeContacts(
+ var permission: Boolean = true,
+ var dates: List = emptyList(),
+ ) : ContactSpecialDatesDataSource {
+ override fun hasPermission() = permission
+ override fun readSpecialDates() = dates
+ }
+
+ private class FakeSpec : SpecialDatesCalendarSpec {
+ override fun displayName(type: SpecialDateType) = type.name
+ override fun color(type: SpecialDateType) = 0xFF112233.toInt()
+ override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
+ SpecialDateType.Birthday -> "{name}'s birthday ({year})"
+ SpecialDateType.Anniversary -> "{name}'s anniversary"
+ SpecialDateType.Custom -> "{name}"
+ }
+ }
+
+ private fun prefs(tempDir: Path): SettingsPrefs {
+ val store: DataStore = PreferenceDataStoreFactory.create(
+ produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
+ )
+ return SettingsPrefs(store)
+ }
+
+ private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
+ ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
+
+ private suspend fun engineWith(
+ tempDir: Path,
+ contacts: FakeContacts,
+ calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
+ enabled: Boolean = true,
+ ): Pair {
+ val settings = prefs(tempDir)
+ if (enabled) settings.setSpecialDatesEnabled(true)
+ return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
+ }
+
+ @Test
+ fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
+ assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled)
+ assertThat(calendars.createdManagedCalendars).isEmpty()
+ }
+
+ @Test
+ fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
+ assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
+ assertThat(calendars.createdManagedCalendars).isEmpty()
+ }
+
+ @Test
+ fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val contacts = FakeContacts(
+ dates = listOf(
+ birthday("a", "Jane", 5, 14, 1990),
+ birthday("b", "Bob", 12, 25, null),
+ ),
+ )
+ val (engine, settings) = engineWith(tempDir, contacts, calendars)
+
+ assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Success)
+ // All three type calendars exist; the birthday one holds both contacts.
+ assertThat(calendars.createdManagedCalendars.map { it.type })
+ .containsExactlyElementsIn(SpecialDateType.entries)
+ assertThat(calendars.insertedManagedEvents).hasSize(2)
+ assertThat(calendars.insertedManagedEvents.map { it.second })
+ .containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
+ // Year-less contact drops the year; year-known keeps it.
+ val titles = calendars.insertedManagedEvents.map { it.first.title }
+ assertThat(titles).contains("Jane's birthday (1990)")
+ assertThat(titles).contains("Bob's birthday")
+ // Calendar ids were persisted for the editor / next sync.
+ assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
+ }
+
+ @Test
+ fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
+ val (engine, _) = engineWith(tempDir, contacts, calendars)
+
+ engine.sync()
+ val insertsAfterFirst = calendars.insertedManagedEvents.size
+ val calendarsAfterFirst = calendars.createdManagedCalendars.size
+ engine.sync()
+
+ assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
+ assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
+ assertThat(calendars.updatedManagedFields).isEmpty()
+ assertThat(calendars.deletedEventIds).isEmpty()
+ }
+
+ @Test
+ fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
+ val (engine, _) = engineWith(tempDir, contacts, calendars)
+ engine.sync()
+
+ contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
+ engine.sync()
+
+ assertThat(calendars.updatedManagedFields).hasSize(1)
+ val (_, columns) = calendars.updatedManagedFields.single()
+ // Only managed columns are ever written — never reminders/location.
+ assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
+ assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)")
+ }
+
+ @Test
+ fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
+ val (engine, _) = engineWith(tempDir, contacts, calendars)
+ engine.sync()
+
+ contacts.dates = emptyList()
+ engine.sync()
+
+ assertThat(calendars.deletedEventIds).hasSize(1)
+ }
+
+ @Test
+ fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
+ engine.sync()
+ val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
+
+ settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
+ engine.sync()
+
+ assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
+ assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
+ }
+
+ @Test
+ fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
+ val (engine, settings) = engineWith(tempDir, contacts, calendars)
+ engine.sync()
+ val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
+
+ engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
+
+ // Persisted as the per-calendar all-day override (so new events match)...
+ assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
+ .isEqualTo(listOf(0, 1440))
+ // ...and applied to the existing events in that calendar.
+ assertThat(calendars.appliedManagedReminders).contains(
+ Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)),
+ )
+ }
+
+ @Test
+ fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
+ val calendars = FakeCalendarDataSource()
+ val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
+ engine.sync()
+ val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
+
+ engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
+
+ assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
+ assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
+ }
+
+ // --- pure diff ---
+
+ private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
+ ManagedEventDesired(uid, title, dt, rrule)
+
+ private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
+ ManagedEventRow(id, uid, title, dt, rrule)
+
+ @Test
+ fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
+ val diff = diffManagedEvents(
+ desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
+ existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
+ )
+ assertThat(diff.insertUids).containsExactly("new")
+ assertThat(diff.deleteEventIds).containsExactly(2L)
+ assertThat(diff.updates).isEmpty()
+ }
+
+ @Test
+ fun `diff emits only the changed managed columns`() {
+ val diff = diffManagedEvents(
+ desired = listOf(desired("a", "Janet", 150)),
+ existing = listOf(row(9, "a", "Jane", 100)),
+ )
+ val update = diff.updates.single()
+ assertThat(update.eventId).isEqualTo(9L)
+ assertThat(update.columns.keys)
+ .containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
index a8cd3fe..8fb656c 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
+import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -465,4 +466,59 @@ class SettingsPrefsTest {
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
}
+
+ @Test
+ fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ assertThat(prefs.specialDatesEnabled.first()).isFalse()
+ assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
+ assertThat(prefs.specialDatesCalendars.first()).isEmpty()
+ assertThat(prefs.managedCalendarIds.first()).isEmpty()
+ assertThat(prefs.specialDatesShowYear.first()).isTrue()
+ }
+
+ @Test
+ fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
+ assertThat(prefs.specialDatesTypes.first())
+ .isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
+ }
+
+ @Test
+ fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
+ prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
+ assertThat(prefs.specialDatesCalendars.first())
+ .isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
+ assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
+
+ prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
+ assertThat(prefs.specialDatesCalendars.first())
+ .isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
+ }
+
+ @Test
+ fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
+ prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
+ assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
+ .isEqualTo("{name}'s birthday")
+ }
+
+ @Test
+ fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ assertThat(prefs.specialDatesStatus.first())
+ .isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
+
+ prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
+ assertThat(prefs.specialDatesStatus.first())
+ .isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
+
+ prefs.recordSpecialDatesRun(2000L, null)
+ assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt
new file mode 100644
index 0000000..1914e0b
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt
@@ -0,0 +1,64 @@
+package de.jeanlucmakiola.calendula.domain.contacts
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class ContactSpecialDateTest {
+
+ @Test
+ fun `parses a full yyyy-MM-dd date with year`() {
+ val parts = parseContactEventDate("1990-05-14")
+ assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
+ }
+
+ @Test
+ fun `parses a year-less --MM-dd date`() {
+ val parts = parseContactEventDate("--05-14")
+ assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
+ }
+
+ @Test
+ fun `parses a compact yyyyMMdd date`() {
+ val parts = parseContactEventDate("19900514")
+ assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
+ }
+
+ @Test
+ fun `parses a year-less MM-dd date without the double-dash prefix`() {
+ val parts = parseContactEventDate("12-25")
+ assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
+ }
+
+ @Test
+ fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
+ val parts = parseContactEventDate("--02-29")
+ assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
+ }
+
+ @Test
+ fun `keeps Feb 29 in a real leap year`() {
+ assertThat(parseContactEventDate("2000-02-29"))
+ .isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
+ }
+
+ @Test
+ fun `rejects Feb 29 in a non-leap year`() {
+ assertThat(parseContactEventDate("1999-02-29")).isNull()
+ }
+
+ @Test
+ fun `rejects an impossible month or day`() {
+ assertThat(parseContactEventDate("2020-13-01")).isNull()
+ assertThat(parseContactEventDate("2020-06-31")).isNull()
+ assertThat(parseContactEventDate("--00-10")).isNull()
+ }
+
+ @Test
+ fun `rejects blank, null and garbage input`() {
+ assertThat(parseContactEventDate(null)).isNull()
+ assertThat(parseContactEventDate("")).isNull()
+ assertThat(parseContactEventDate(" ")).isNull()
+ assertThat(parseContactEventDate("not-a-date")).isNull()
+ assertThat(parseContactEventDate("2020")).isNull()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt
new file mode 100644
index 0000000..47bcb83
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt
@@ -0,0 +1,78 @@
+package de.jeanlucmakiola.calendula.domain.contacts
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.datetime.LocalDate
+import org.junit.jupiter.api.Test
+
+class SpecialDateFormattingTest {
+
+ private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
+ lookupKey = "k",
+ displayName = "Jane",
+ type = SpecialDateType.Birthday,
+ month = month,
+ day = day,
+ year = year,
+ )
+
+ @Test
+ fun `uid is deterministic and namespaced by type`() {
+ assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
+ .isEqualTo("contact-birthday:abc@calendula")
+ assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
+ .isEqualTo("contact-anniversary:abc@calendula")
+ }
+
+ private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate(
+ lookupKey = "k",
+ displayName = "Jane",
+ type = SpecialDateType.Custom,
+ month = month,
+ day = day,
+ year = null,
+ label = label,
+ )
+
+ @Test
+ fun `birthday and anniversary uids ignore the discriminator`() {
+ assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula")
+ }
+
+ @Test
+ fun `two custom dates on one contact get distinct uids by label`() {
+ assertThat(custom("Wedding", 6, 10).managedUid())
+ .isEqualTo("contact-custom:k:wedding@calendula")
+ assertThat(custom("Graduation", 9, 1).managedUid())
+ .isNotEqualTo(custom("Wedding", 6, 10).managedUid())
+ }
+
+ @Test
+ fun `label-less custom dates fall back to month-day so distinct dates survive`() {
+ assertThat(custom(null, 6, 10).managedUid())
+ .isEqualTo("contact-custom:k:06-10@calendula")
+ assertThat(custom(null, 9, 1).managedUid())
+ .isNotEqualTo(custom(null, 6, 10).managedUid())
+ }
+
+ @Test
+ fun `anchor date uses the known year`() {
+ assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
+ }
+
+ @Test
+ fun `anchor date falls back to a leap year when year-less`() {
+ assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
+ }
+
+ @Test
+ fun `title substitutes name and year`() {
+ assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
+ .isEqualTo("Jane's birthday (1990)")
+ }
+
+ @Test
+ fun `title drops an empty year parenthesis and tidies spacing`() {
+ assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
+ .isEqualTo("Jane's birthday")
+ }
+}
diff --git a/docs/design/contact-special-dates.md b/docs/design/contact-special-dates.md
new file mode 100644
index 0000000..e92965d
--- /dev/null
+++ b/docs/design/contact-special-dates.md
@@ -0,0 +1,248 @@
+# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
+
+Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
+Date: 2026-06-30 (implemented 2026-07-01)
+Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
+
+Implementation notes / deviations from this design:
+
+- **Event identity key is a deterministic `Events.UID_2445`**
+ (`contact-:@calendula`), not `SYNC_DATA1`. On a LOCAL calendar
+ the provider drops `_sync` columns unless written through a sync-adapter URI;
+ `UID_2445` is written/read through the normal event URIs the app already uses
+ (and doubles as a stable iCal UID). The managed **calendar** still carries a
+ `CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
+ sync-adapter URI).
+- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
+ age is impossible without per-year exception rows (rejected as too heavy). The
+ template instead offers `{year}` — the birth year (or an anniversary's start
+ year) — which is static and correct on every occurrence. A "Show year" toggle
+ gates it; it's in the default templates ("{name}'s birthday ({year})").
+- New managed calendars seed a per-calendar all-day reminder default of
+ on-the-day + one week before, so birthdays get lead time out of the box.
+- Title template is user-editable per type from day one (`{name}`, `{year}`).
+
+This document captures the full design for surfacing contact birthdays —
+and, by extension, anniversaries and custom dates — as auto-updating local
+calendars in Calendula. It also specifies #14 (per-calendar *multiple*
+default reminders), which is a hard prerequisite and ships first.
+
+The two pieces are deliberately split across **two branches / two
+shipments**:
+
+- `feat/per-calendar-multi-reminders` — #14, ships first.
+- `feat/contact-special-dates` — #15, builds on #14.
+
+They *may* release together depending on timing, but #14 is independently
+useful and is the foundation the special-dates calendars lean on.
+
+---
+
+## Why this is worth doing carefully
+
+Calendula's identity is privacy: **no INTERNET permission**, and historically
+**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
+never holds the contacts permission (`EventEditScreen.kt:446-460`).
+
+This feature **declares `READ_CONTACTS`** for the first time. That is a
+deliberate, owner-approved shift, mitigated by:
+
+- The permission is **opt-in and feature-gated** — requested only when the user
+ enables the feature, never at startup.
+- Everything stays **offline**. Contacts are read locally and mirrored into a
+ local calendar; nothing leaves the device.
+- The mirror is **one-way** (contacts → calendar). Calendula never writes to
+ Contacts.
+
+Store listing / About copy will need to explain the optional permission.
+
+---
+
+## Prerequisite — #14: per-calendar *multiple* default reminders
+
+### Today
+
+- Events already support **multiple reminders** — `EventForm.reminders:
+ List` (`EventForm.kt:24`), each written as a separate
+ `CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each
+ firing independently. The runtime/notification path is done.
+- The **defaults** layer is single-valued, though:
+ - Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single
+ `Int` (`SettingsPrefs.kt:287,302`).
+ - Per-calendar override: `perCalendarReminderOverride: Map` and
+ the all-day variant (`SettingsPrefs.kt:350-381`), resolved by
+ `resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`).
+ - UI: expandable per-calendar override section
+ (`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`).
+
+### The change
+
+Widen the **defaults**, not the runtime, from one reminder to a list:
+
+1. Resolution model `Int` → `List` in global defaults + per-calendar
+ override + `resolveDefaultReminder()`. The DataStore string format already
+ parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
+ comma-separated list. Keep the `Inherit / None / Minutes` override states
+ (`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
+ a list.
+2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
+ UI rather than inventing new interaction.
+3. New events seed `EventForm.reminders` from the **resolved list** instead of
+ a single value.
+
+No new permissions, no provider changes, no scheduling changes. Type widening
+plus one picker upgrade.
+
+### Why it gates #15
+
+Each special-dates calendar is a normal local calendar, so it inherits the
+**per-calendar reminder default** for free. A birthdays calendar is useless
+without lead time (the reporter literally asked for "a week before *and* on the
+day") — that requires *multiple* defaults, i.e. #14.
+
+---
+
+## #15: Contact special-dates calendars
+
+### Core model: one-way mirror, one local calendar per type
+
+Contacts → **separate local calendars per type**:
+
+- "Birthdays"
+- "Anniversaries"
+- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
+
+Separate-per-type is the key architectural decision: each is a normal local
+calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
+**existing per-calendar infra applies for free**:
+
+- **Color** → existing per-calendar color.
+- **Show/hide** → existing calendar visibility.
+- **Default reminders** → per-calendar override from #14.
+
+This shrinks the new surface dramatically: anniversaries and custom dates are
+near copy-paste of birthdays (same engine, different `ContactsContract` data
+kind, one extra local calendar each), and the settings sub-page does **not**
+need to reinvent color/visibility/reminders.
+
+### Events: field-level managed, not read-only
+
+Calendula manages a few fields; the user owns the rest.
+
+- **Managed** (overwritten on every sync, disabled in the editor for these
+ events): title, start date, recurrence (`FREQ=YEARLY`), existence.
+- **User-owned** (seeded once on insert, never touched again by sync):
+ location, reminders, notes, busy/free.
+
+The rule that makes this safe: sync performs a **targeted column update**
+(managed columns only), **never delete-and-reinsert**. Reminders live in
+separate rows, so once seeded they are simply never re-touched. A user can move
+a birthday reminder to "2 weeks before" or add a location and neither is
+clobbered.
+
+In the editor, when an event belongs to a managed calendar: disable the
+title/date/recurrence fields; allow the rest.
+
+### Reconciliation identity
+
+Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
+since it's a local calendar). Sync becomes an idempotent diff keyed on it:
+
+- contact added → insert
+- birthday/date changed → targeted update of managed columns
+- contact or date removed → delete the event
+
+Getting this wrong = duplicate birthdays on every sync, so the identity key is
+non-negotiable.
+
+### Data source
+
+`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
+`Event.TYPE`:
+
+- `TYPE_BIRTHDAY` → Birthdays calendar
+- `TYPE_ANNIVERSARY` → Anniversaries calendar
+- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
+
+### Dates: year-less + age
+
+Many contacts store `--MM-DD` (no year). Handle both:
+
+- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
+ age ("Jane turns 30") when the show-age option is on.
+- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
+
+Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
+(`Recurrence.kt:32-174`).
+
+### Auto-update cadence
+
+Birthdays change rarely, so nothing live/expensive:
+
+- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
+ (catches new/edited/deleted contacts even if the app isn't opened).
+- **No `ContentObserver`** — needs the process alive and buys almost nothing
+ for once-a-year events.
+
+### Permission + lifecycle
+
+- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
+ contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
+ grant, create the enabled calendars and run the first sync.
+- **Disable:** delete the managed calendars + their events (with a confirm).
+- **Permission revoked in system settings:** detect on next sync; surface that
+ the feature is stalled rather than failing silently.
+
+### Configurable settings sub-page
+
+Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
+color/visibility/reminders already live in normal calendar settings, the
+sub-page only carries the genuinely new knobs:
+
+- Master enable toggle.
+- Per-type toggles: Birthdays / Anniversaries / Custom dates.
+- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
+- Show-age toggle (only meaningful when the year is known).
+- (Possibly) sync-cadence / "sync now".
+- Pointer to the normal calendar settings for color, visibility, reminders.
+
+The owner wants this broadly configurable — keep the sub-page the home for
+display/behaviour options, and lean on existing per-calendar settings for the
+rest.
+
+---
+
+## Open questions / deferred
+
+- Title template: free-form vs a small set of presets. Start with one good
+ localized default; revisit configurability.
+- Whether "Custom dates" is one bucket or split further by label. Start: one
+ bucket.
+- Contact scope: all contacts vs per-account/group filtering. Start: all;
+ filtering is a later refinement.
+
+## Task outline
+
+### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
+- [x] Widen global + per-calendar reminder defaults `Int` → `List`
+ (`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
+- [x] `resolveDefaultReminder()` returns a list.
+- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
+- [x] Seed new events' `EventForm.reminders` from the resolved list.
+- [x] Migration / parse compatibility for existing single-value stored prefs.
+- [x] Tests: resolution, parse round-trip, all-day variant.
+
+### #15 — contact special-dates (`feat/contact-special-dates`)
+- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
+- [x] Per-type local calendars (create on enable, delete on disable+confirm).
+- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
+- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
+ (see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
+ user-owned fields on insert only.
+- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct).
+- [x] Auto-update: on enable, on foreground, daily WorkManager.
+- [x] Editor: disable managed fields for managed-calendar events.
+- [x] Revoked-permission detection + stalled-state surface.
+- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
+- [x] Translatable strings (titles, sub-page).
+- [x] About / store copy for the optional `READ_CONTACTS` permission.
diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt
index 5055210..96ec0e3 100644
--- a/fastlane/metadata/android/de-DE/full_description.txt
+++ b/fastlane/metadata/android/de-DE/full_description.txt
@@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
Termin.
+Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage
+deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte
+werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert —
+Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll
+kontrollierst.
+
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index f59f242..2ebf536 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a
simple repeat picker. Calendula also delivers your event reminders as
notifications — tap one and you're on the event.
+Optionally, show your contacts' birthdays, anniversaries and other special
+dates in your calendar. You turn this on yourself; contacts are read only on
+your device, are never uploaded, and are never changed — Calendula only
+mirrors the dates into local calendars you fully control.
+
The differentiator is the design: real Material 3 Expressive throughout,
with dynamic color, expressive motion, and expressive shapes.