From 25ff7ce7caad4a2e1c8ad5da975eec717e94dee6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 30 Jun 2026 14:58:14 +0200 Subject: [PATCH 01/23] docs(design): contact special-dates calendars + per-calendar multiple reminders Capture the full design for surfacing contact birthdays/anniversaries/custom dates as auto-updating local calendars (Codeberg #15), plus the #14 prerequisite (per-calendar multiple default reminders). One local calendar per type to reuse existing per-calendar color/visibility/reminder infra; one-way mirror with field-level managed events keyed on contact LOOKUP_KEY. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/contact-special-dates.md | 229 +++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/design/contact-special-dates.md diff --git a/docs/design/contact-special-dates.md b/docs/design/contact-special-dates.md new file mode 100644 index 0000000..65bebb1 --- /dev/null +++ b/docs/design/contact-special-dates.md @@ -0,0 +1,229 @@ +# Design: Contact special-dates calendars (+ per-calendar multiple reminders) + +Status: **design / not yet implemented** +Date: 2026-06-30 +Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite) + +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`) +- [ ] Widen global + per-calendar reminder defaults `Int` → `List` + (`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`. +- [ ] `resolveDefaultReminder()` returns a list. +- [ ] `ReminderDefaultPicker` → multi-select (mirror event-form UI). +- [ ] Seed new events' `EventForm.reminders` from the resolved list. +- [ ] Migration / parse compatibility for existing single-value stored prefs. +- [ ] Tests: resolution, parse round-trip, all-day variant. + +### #15 — contact special-dates (`feat/contact-special-dates`) +- [ ] Declare `READ_CONTACTS`; feature-gated contextual request on enable. +- [ ] Per-type local calendars (create on enable, delete on disable+confirm). +- [ ] Contacts reader: `Event` rows by `TYPE`, year-less handling. +- [ ] Sync engine: idempotent diff keyed on `LOOKUP_KEY` in `SYNC_DATA1`; + targeted managed-column updates; seed user-owned fields on insert only. +- [ ] `FREQ=YEARLY` event generation + age (when year known). +- [ ] Auto-update: on enable, on foreground, daily WorkManager. +- [ ] Editor: disable managed fields for managed-calendar events. +- [ ] Revoked-permission detection + stalled-state surface. +- [ ] Settings sub-page: enable, per-type toggles, title template, show-age. +- [ ] Translatable strings (titles, sub-page). +- [ ] About / store copy for the optional `READ_CONTACTS` permission. From 93ca38d974dd7664231ee0dea148d48e5d5764ed Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 10:19:23 +0200 Subject: [PATCH 02/23] feat(contacts): declare READ_CONTACTS + read contact special-dates Codeberg #15, foundation for the contact special-dates calendars. Declares the optional, feature-gated READ_CONTACTS permission (never requested at startup) and adds the offline, read-only contacts reader. - AndroidManifest: READ_CONTACTS with a comment documenting the opt-in/offline one-way-mirror contract. - domain/contacts: SpecialDateType + ContactSpecialDate model and a pure parseContactEventDate covering full (yyyy-MM-dd), year-less (--MM-dd) and compact (yyyyMMdd) shapes, with Feb-29 handling via a leap anchor. - data/contacts: ContactSpecialDatesDataSource querying ContactsContract.Data Event rows, split by TYPE, deduped per (contact, type); returns empty without the permission so sync can degrade to a stalled state. Hilt-bound. - Unit tests for the date parser (full/year-less/compact/Feb-29/malformed). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 9 ++ .../contacts/ContactSpecialDatesDataSource.kt | 98 ++++++++++++++ .../calendula/data/di/DataModule.kt | 8 ++ .../domain/contacts/ContactSpecialDate.kt | 122 ++++++++++++++++++ .../domain/contacts/ContactSpecialDateTest.kt | 64 +++++++++ 5 files changed, 301 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDate.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt 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 @@ + + + Birthdays + Anniversaries + Special dates + {name}\'s birthday ({age}) + {name}\'s anniversary ({age}) + {name} 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 b2832b8..edbff7f 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 @@ -86,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. */ @@ -147,9 +149,9 @@ internal class FakeCalendarDataSource : CalendarDataSource { // --- Managed special-dates surface (stateful, so a re-sync sees inserts) --- - var findManagedCalendarsResult: List = emptyList() 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) @@ -157,15 +159,22 @@ internal class FakeCalendarDataSource : CalendarDataSource { 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 = findManagedCalendarsResult + override fun findManagedCalendars(): List = managedCalendarRows.toList() override fun queryManagedEvents(calendarId: Long): List = managedEvents[calendarId]?.toList() ?: emptyList() 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..c24a411 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -0,0 +1,196 @@ +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.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 kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class SpecialDatesSyncEngineTest { + + private val today = LocalDate(2026, 3, 1) + + 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 ({age})" + 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(today)).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(today)).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(today)).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 age; year-known keeps it. + val titles = calendars.insertedManagedEvents.map { it.first.title } + assertThat(titles).contains("Jane's birthday (36)") + 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(today) + val insertsAfterFirst = calendars.insertedManagedEvents.size + val calendarsAfterFirst = calendars.createdManagedCalendars.size + engine.sync(today) + + 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(today) + + contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990)) + engine.sync(today) + + 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 (36)") + } + + @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(today) + + contacts.dates = emptyList() + engine.sync(today) + + 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(today) + val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary) + + settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) + engine.sync(today) + + assertThat(calendars.deletedCalendarIds).contains(anniversaryId) + assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) + } + + // --- 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/domain/contacts/SpecialDateFormattingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt new file mode 100644 index 0000000..393ccbe --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -0,0 +1,69 @@ +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") + } + + @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 `age is null when the year is unknown`() { + assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull() + } + + @Test + fun `age counts the upcoming birthday when it is still ahead this year`() { + // Born 1990-05-14; today 2026-03-01 → next birthday 2026 → turns 36. + assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 3, 1))).isEqualTo(36) + } + + @Test + fun `age rolls to next year once the birthday has passed`() { + // Born 1990-05-14; today 2026-06-01 → next birthday 2027 → turns 37. + assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 6, 1))).isEqualTo(37) + } + + @Test + fun `age counts today's birthday as this year`() { + assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 5, 14))).isEqualTo(36) + } + + @Test + fun `title substitutes name and age`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", 30)) + .isEqualTo("Jane's birthday (30)") + } + + @Test + fun `title drops an empty age parenthesis and tidies spacing`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", null)) + .isEqualTo("Jane's birthday") + } +} From 4540faf2a0d9c4954e08d70e25894e0cbe609a92 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:01:29 +0200 Subject: [PATCH 06/23] feat(contacts): schedule + lifecycle for the special-dates mirror Mirrors the auto-backup infrastructure. A daily periodic WorkManager job plus an immediate run on enable/"Sync now" and a debounced foreground run keep the mirror fresh without a ContentObserver. - SpecialDatesScheduler.apply/runNow + SpecialDatesSyncWorker (EntryPoint): respects the toggle, debounces foreground runs (4h), and parks the feature in a PermissionRevoked stalled state (recorded for the settings banner) instead of retrying forever when READ_CONTACTS is gone. - CalendulaApp.onCreate reconciles the schedule on every launch. - RootScreen ON_RESUME enqueues a debounced foreground sync, gated on the READ_CONTACTS grant so opted-out users never enqueue it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/CalendulaApp.kt | 20 +++ .../data/contacts/SpecialDatesScheduler.kt | 123 ++++++++++++++++++ .../jeanlucmakiola/calendula/ui/RootScreen.kt | 10 ++ 3 files changed, 153 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index ac300d1..4ce0165 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupWorker +import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler +import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.crash.CrashReporter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,6 +27,7 @@ class CalendulaApp : Application() { // respecting, on-device; the user submits the report by hand). CrashReporter.install(this) reconcileAutoBackup() + reconcileSpecialDates() } /** @@ -44,4 +47,21 @@ class CalendulaApp : Application() { ) } } + + /** + * Re-arm (or cancel) the daily special-dates reconcile from the saved + * settings on every launch — like [reconcileAutoBackup], this cancels + * orphaned work after the feature is turned off and re-schedules after a + * reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open + * refresh, so no immediate run is needed here. + */ + private fun reconcileSpecialDates() { + val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java) + CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + SpecialDatesScheduler.apply( + context = this@CalendulaApp, + enabled = deps.settingsPrefs().specialDatesEnabled.first(), + ) + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt new file mode 100644 index 0000000..f5237b7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt @@ -0,0 +1,123 @@ +package de.jeanlucmakiola.calendula.data.contacts + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +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 + * is deliberately cheap: a **daily** periodic reconcile, plus an immediate run + * on enable / "Sync now" and a debounced one when the app is foregrounded. No + * ContentObserver — it would need the process alive and buys almost nothing for + * once-a-year events. Everything stays offline (local contacts → local calendar). + */ +object SpecialDatesScheduler { + + private const val WORK_NAME = "special-dates-sync" + private const val WORK_NAME_NOW = "special-dates-sync-now" + private const val KEY_FOREGROUND = "foreground" + + /** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */ + fun apply(context: Context, enabled: Boolean) { + val workManager = WorkManager.getInstance(context) + if (!enabled) { + workManager.cancelUniqueWork(WORK_NAME) + workManager.cancelUniqueWork(WORK_NAME_NOW) + return + } + val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + // Delay the first periodic run so it never overlaps an immediate run. + .setInitialDelay(1, TimeUnit.DAYS) + .build() + workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request) + } + + /** + * Run one reconcile immediately. [foreground] runs are debounced (the app can + * resume often); enable/"Sync now" runs ([foreground] false) always sync. + */ + fun runNow(context: Context, foreground: Boolean = false) { + val request = OneTimeWorkRequestBuilder() + .setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build()) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request) + } + + internal const val INPUT_FOREGROUND = KEY_FOREGROUND +} + +/** + * Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt + * [EntryPoint] so it works under WorkManager's default worker factory. Records + * the run for the settings status line; a missing permission parks the feature + * in a stalled state (surfaced in settings) rather than retrying forever. + */ +class SpecialDatesSyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Deps { + fun settingsPrefs(): SettingsPrefs + fun syncEngine(): SpecialDatesSyncEngine + } + + override suspend fun doWork(): Result { + val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) + val prefs = deps.settingsPrefs() + // Respect the toggle even for already-queued work; never revive a + // disabled feature. + if (!prefs.specialDatesEnabled.first()) return Result.success() + + val now = System.currentTimeMillis() + // A foreground resume can fire often — skip if we synced recently. + val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false) + if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) { + return Result.success() + } + + val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) + return try { + val result = deps.syncEngine().sync(today) + val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) { + SpecialDatesStalledReason.PermissionRevoked + } else { + null + } + prefs.recordSpecialDatesRun(now, stalled) + if (foreground) prefs.setSpecialDatesLastForegroundSync(now) + Result.success() + } catch (e: Exception) { + Log.w(TAG, "Special-dates sync failed", e) + Result.retry() + } + } + + companion object { + private const val TAG = "SpecialDatesSync" + + /** Skip a foreground-triggered sync if one ran within this window (4h). */ + private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 1ab1f0f..d3da90d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel @@ -49,6 +50,15 @@ fun RootScreen( hasPermission = ContextCompat.checkSelfPermission( context, Manifest.permission.READ_CALENDAR ) == PackageManager.PERMISSION_GRANTED + // Refresh the contact special-dates mirror on foreground (the + // worker is debounced and no-ops when the feature is off). Gated + // on the permission so users without the opt-in never enqueue it. + if (ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + ) { + SpecialDatesScheduler.runNow(context, foreground = true) + } } } lifecycle.addObserver(obs) From 4da9abe05bec11e117493535846fac70b91d2bb0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:06:00 +0200 Subject: [PATCH 07/23] feat(contacts): lock managed fields in the event editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When editing an event in a special-dates calendar, the title, all-day/date and recurrence are locked (the sync overwrites them) while reminders, location, notes and colour stay editable — the field-level "managed" contract from the design. - EventEditUiState.isManaged (calendar id ∈ managedCalendarIds while editing), wired through the ViewModel's state combine. - InlineTextField/InlineField gain an `enabled` flag (dims + goes read-only); ScheduleRow dims and disables its date/time taps; the all-day switch and the recurrence card are disabled. - A one-line "Managed by …" hint under the title explains what's editable. The calendar picker needs no change — it's already create-only, and managed events are always edits. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/InlineTextField.kt | 11 +++-- .../calendula/ui/edit/EventEditScreen.kt | 44 +++++++++++++++---- .../calendula/ui/edit/EventEditUiState.kt | 6 +++ .../calendula/ui/edit/EventEditViewModel.kt | 5 ++- app/src/main/res/values/strings.xml | 2 + 5 files changed, 54 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt index 6a541f1..5bf2f56 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt @@ -36,6 +36,7 @@ fun InlineTextField( textStyle: TextStyle = MaterialTheme.typography.titleMedium, singleLine: Boolean = true, minLines: Int = 1, + enabled: Boolean = true, keyboardType: KeyboardType = KeyboardType.Text, capitalization: KeyboardCapitalization = KeyboardCapitalization.None, imeAction: ImeAction = ImeAction.Default, @@ -43,15 +44,17 @@ fun InlineTextField( onImeAction: (() -> Unit)? = null, ) { val resolvedStyle = textStyle.copy( - color = if (textStyle.color.isSpecified) { - textStyle.color - } else { - MaterialTheme.colorScheme.onSurface + color = when { + // A disabled field reads as dimmed, like a locked value. + !enabled -> MaterialTheme.colorScheme.onSurfaceVariant + textStyle.color.isSpecified -> textStyle.color + else -> MaterialTheme.colorScheme.onSurface }, ) BasicTextField( value = value, onValueChange = onValueChange, + enabled = enabled, textStyle = resolvedStyle, singleLine = singleLine, minLines = minLines, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 166c0fd..2fd6830 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -435,6 +435,10 @@ private fun EventEditContent( val form = state.form val locale = currentLocale() val dark = isSystemInDarkTheme() + // The title, date and recurrence are managed by the special-dates sync, so + // they're locked here; everything else (reminders, location, notes) is the + // user's to edit. + val locked = state.isManaged var picker by remember { mutableStateOf(null) } var showCalendarPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) } @@ -519,6 +523,7 @@ private fun EventEditContent( placeholder = stringResource(R.string.event_edit_title_hint), textStyle = MaterialTheme.typography.headlineMedium .copy(fontWeight = FontWeight.SemiBold), + enabled = !locked, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) @@ -531,6 +536,17 @@ private fun EventEditContent( .height(3.dp) .background(accent, RoundedCornerShape(2.dp)), ) + if (locked) { + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource( + R.string.event_edit_managed_hint, + selectedCalendar?.displayName.orEmpty(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Spacer(Modifier.height(20.dp)) @@ -549,7 +565,11 @@ private fun EventEditContent( style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) - Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay) + Switch( + checked = form.isAllDay, + onCheckedChange = viewModel::setAllDay, + enabled = !locked, + ) } }, ) { @@ -561,6 +581,7 @@ private fun EventEditContent( locale = locale, onDateClick = { picker = PickerTarget.StartDate }, onTimeClick = { picker = PickerTarget.StartTime }, + enabled = !locked, ) ScheduleRow( label = stringResource(R.string.event_edit_ends), @@ -570,6 +591,7 @@ private fun EventEditContent( onDateClick = { picker = PickerTarget.EndDate }, onTimeClick = { picker = PickerTarget.EndTime }, isError = EventFormProblem.EndBeforeStart in state.problems, + enabled = !locked, ) if (EventFormProblem.EndBeforeStart in state.problems) { Spacer(Modifier.height(2.dp)) @@ -700,7 +722,7 @@ private fun EventEditContent( EditCard( icon = Icons.Default.Repeat, iconContentDescription = null, - onClick = { showRecurrencePicker = true }, + onClick = if (locked) null else ({ showRecurrencePicker = true }), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -1858,6 +1880,7 @@ private fun InlineField( textStyle: TextStyle = MaterialTheme.typography.titleMedium, singleLine: Boolean = true, minLines: Int = 1, + enabled: Boolean = true, keyboardType: KeyboardType = KeyboardType.Text, modifier: Modifier = Modifier .fillMaxWidth() @@ -1871,6 +1894,7 @@ private fun InlineField( textStyle = textStyle, singleLine = singleLine, minLines = minLines, + enabled = enabled, keyboardType = keyboardType, ) } @@ -1885,6 +1909,7 @@ private fun ScheduleRow( onDateClick: () -> Unit, onTimeClick: () -> Unit, isError: Boolean = false, + enabled: Boolean = true, ) { val dateFormat = remember(locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) @@ -1894,11 +1919,12 @@ private fun ScheduleRow( timeOfDayFormatter(use24Hour, locale) } // Tappable values read as links (primary), like the location on the - // detail screen; errors flip them to the error colour. - val valueColor = if (isError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary + // detail screen; errors flip them to the error colour; a locked (managed) + // row dims to a plain, non-tappable value. + val valueColor = when { + !enabled -> MaterialTheme.colorScheme.onSurfaceVariant + isError -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary } Row( verticalAlignment = Alignment.CenterVertically, @@ -1915,7 +1941,7 @@ private fun ScheduleRow( style = MaterialTheme.typography.titleMedium, color = valueColor, modifier = Modifier - .clickable(onClick = onDateClick) + .clickable(enabled = enabled, onClick = onDateClick) .padding(vertical = 8.dp, horizontal = 6.dp), ) if (!isAllDay) { @@ -1924,7 +1950,7 @@ private fun ScheduleRow( style = MaterialTheme.typography.titleMedium, color = valueColor, modifier = Modifier - .clickable(onClick = onTimeClick) + .clickable(enabled = enabled, onClick = onTimeClick) .padding(vertical = 8.dp, horizontal = 6.dp), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 04b063d..934b792 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -29,6 +29,12 @@ data class EventEditUiState( val hiddenFields: List = emptyList(), /** True while editing an existing event (the calendar is then fixed). */ val isEditing: Boolean = false, + /** + * True when the event's calendar is a contact special-dates calendar the app + * manages. While editing such an event the title, date and recurrence are + * locked (sync overwrites them); reminders, location and notes stay editable. + */ + val isManaged: Boolean = false, /** * Whether to focus the title and raise the keyboard when this is a fresh * create form (issue #10). Mirrors the settings flag; the screen only acts diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 7190beb..673501a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -165,7 +165,8 @@ class EventEditViewModel @Inject constructor( ::ExternalInputs, ).flowOn(io), colorPalette, - ) { local, external, palette -> + settingsPrefs.managedCalendarIds, + ) { local, external, palette, managedIds -> val form = local.form ?: return@combine null val resolvedId = form.calendarId ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } @@ -180,6 +181,8 @@ class EventEditViewModel @Inject constructor( visibleFields = visibleFields, hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), isEditing = local.editTarget != null, + // Editing an event in a managed calendar locks its synced fields. + isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds, autofocusTitle = external.autofocusTitle, // A modified-occurrence exception can't carry its own rule, so // the scope dialog drops "only this event" after a rule change. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46446bd..e1218ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -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 From 22bbfef3e2cf78f2ce7d55a151e39f1cce1b88d3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:12:08 +0200 Subject: [PATCH 08/23] 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 From e67960d3527b5f8f496135b4eea875cfaaa3cef2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:16:11 +0200 Subject: [PATCH 09/23] docs(contacts): translations, store copy + changelog for special dates - German translations for the special-dates sub-page, the managed-field editor hint, and the calendar/title strings. - Store listing (en/de): a paragraph on the optional, on-device, one-way contact special-dates feature. - Soften the calendar-permission rationale ("all it asks for up front") now that an optional contacts permission exists, without weakening the privacy promise. - CHANGELOG entry for #15. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 +++++++ app/src/main/res/values-de/strings.xml | 34 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 +- .../android/de-DE/full_description.txt | 6 ++++ .../android/en-US/full_description.txt | 5 +++ 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d973ab..32f6971 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}`, `{age}`). + 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, @@ -752,5 +763,6 @@ 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d4f6945..9d00942 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -13,7 +13,7 @@ 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. @@ -410,4 +410,36 @@ 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 ({age}) + {name}s Jahrestag ({age}) + {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 {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist). + Alter anzeigen + {age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist + Jetzt synchronisieren + Noch nicht synchronisiert + Zuletzt synchronisiert %1$s + Farbe, Sichtbarkeit und Erinnerungen jedes Kalenders legst du in den Kalender-Einstellungen fest. + 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 48b6856..1a93edd 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. 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. From 030c3e6ce10e226bf06c5065a1c9272a28b847b7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:17:23 +0200 Subject: [PATCH 10/23] docs(design): mark contact special-dates implemented + note deviations Records the UID_2445 (vs SYNC_DATA1) identity decision, the sync-time age snapshot, and the seeded reminder default; checks off the #14/#15 task lists. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/contact-special-dates.md | 57 ++++++++++++++++++---------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/design/contact-special-dates.md b/docs/design/contact-special-dates.md index 65bebb1..6af36a4 100644 --- a/docs/design/contact-special-dates.md +++ b/docs/design/contact-special-dates.md @@ -1,9 +1,25 @@ # Design: Contact special-dates calendars (+ per-calendar multiple reminders) -Status: **design / not yet implemented** -Date: 2026-06-30 +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). +- **Age is a sync-time snapshot.** A `FREQ=YEARLY` row has one static title, so + `{age}` reflects the upcoming birthday at the last sync (refreshed daily / on + foreground), not a per-occurrence value. +- 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 from day one (`{name}`, `{age}`). + 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* @@ -206,24 +222,25 @@ rest. ## Task outline ### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`) -- [ ] Widen global + per-calendar reminder defaults `Int` → `List` +- [x] Widen global + per-calendar reminder defaults `Int` → `List` (`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`. -- [ ] `resolveDefaultReminder()` returns a list. -- [ ] `ReminderDefaultPicker` → multi-select (mirror event-form UI). -- [ ] Seed new events' `EventForm.reminders` from the resolved list. -- [ ] Migration / parse compatibility for existing single-value stored prefs. -- [ ] Tests: resolution, parse round-trip, all-day variant. +- [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`) -- [ ] Declare `READ_CONTACTS`; feature-gated contextual request on enable. -- [ ] Per-type local calendars (create on enable, delete on disable+confirm). -- [ ] Contacts reader: `Event` rows by `TYPE`, year-less handling. -- [ ] Sync engine: idempotent diff keyed on `LOOKUP_KEY` in `SYNC_DATA1`; - targeted managed-column updates; seed user-owned fields on insert only. -- [ ] `FREQ=YEARLY` event generation + age (when year known). -- [ ] Auto-update: on enable, on foreground, daily WorkManager. -- [ ] Editor: disable managed fields for managed-calendar events. -- [ ] Revoked-permission detection + stalled-state surface. -- [ ] Settings sub-page: enable, per-type toggles, title template, show-age. -- [ ] Translatable strings (titles, sub-page). -- [ ] About / store copy for the optional `READ_CONTACTS` permission. +- [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 + age (when year known; sync-time snapshot). +- [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. From 0fd579b19b41819d99c685e7af66fb508c904c09 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:51:42 +0200 Subject: [PATCH 11/23] feat(contacts): drop {age} from default titles, disclaim it when added A FREQ=YEARLY event has one static title, so {age} is only a sync-time snapshot and can look wrong on far-future occurrences. Keep it out of the default templates ({name}'s birthday / anniversary), and when a user does add {age} in the title-format editor, show a short disclaimer about the snapshot behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 10 ++++++++++ app/src/main/res/values-de/strings.xml | 5 +++-- app/src/main/res/values/strings.xml | 5 +++-- docs/design/contact-special-dates.md | 4 +++- 4 files changed, 19 insertions(+), 5 deletions(-) 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 92220c5..9e096fd 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 @@ -1251,6 +1251,16 @@ 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 = { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9d00942..d8246bb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -415,8 +415,8 @@ Geburtstage Jahrestage Besondere Tage - {name}s Geburtstag ({age}) - {name}s Jahrestag ({age}) + {name}s Geburtstag + {name}s Jahrestag {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 @@ -428,6 +428,7 @@ Weitere Tage Titelformat Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist). + Hinweis: {age} ist das Alter zum nächsten Termin beim letzten Sync. Da ein jährlicher Termin nur einen Titel hat, kann es bei weit entfernten Terminen unpassend wirken. Alter anzeigen {age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist Jetzt synchronisieren diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a93edd..b4c8f0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,6 +357,7 @@ Other dates Title format Use {name} for the contact and {age} for their age (hidden when the year is unknown). + Note: {age} is the age at the next date as of the last sync. Because a yearly event has one title, it can look off on occurrences far in the future. Show age Include {age} in titles when the birth year is known Sync now @@ -497,7 +498,7 @@ Birthdays Anniversaries Special dates - {name}\'s birthday ({age}) - {name}\'s anniversary ({age}) + {name}\'s birthday + {name}\'s anniversary {name} diff --git a/docs/design/contact-special-dates.md b/docs/design/contact-special-dates.md index 6af36a4..2b27c88 100644 --- a/docs/design/contact-special-dates.md +++ b/docs/design/contact-special-dates.md @@ -15,7 +15,9 @@ Implementation notes / deviations from this design: sync-adapter URI). - **Age is a sync-time snapshot.** A `FREQ=YEARLY` row has one static title, so `{age}` reflects the upcoming birthday at the last sync (refreshed daily / on - foreground), not a per-occurrence value. + foreground), not a per-occurrence value. Because of this, `{age}` is **kept out + of the default templates**; users can add it, and the title-format editor shows + a disclaimer once they do. - 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 from day one (`{name}`, `{age}`). From cbb241bc4d85a47d53ba2a58673f5bad23d0d1ec Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:41:52 +0200 Subject: [PATCH 12/23] feat(contacts): calendar-wide reminders for special-dates calendars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed calendars now treat reminders as a calendar-level setting instead of the seed-once-per-event model, matching how these homogeneous birthday/anniversary calendars are actually used. - New per-type "Reminders" control in the Contact special dates section. Changing it persists the per-calendar all-day override (so new events match) AND re-applies the set to *all existing events* in that calendar (CalendarDataSource.applyManagedCalendarReminders → SpecialDatesSyncEngine .applyReminders), encoding each event's all-day offset from its own date. - Settings → Notifications: the contact-date calendars no longer offer a per-calendar override row; they show a link that jumps to the Contact special dates section (managedCalendarIds now in SettingsUiState). Tests cover applyReminders (override persisted + bulk-apply invoked, and None clears). lint + unit tests + assembleDebug green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 40 ++++++++++++ .../data/contacts/SpecialDatesSyncEngine.kt | 22 +++++++ .../calendula/ui/settings/SettingsScreen.kt | 62 ++++++++++++++++-- .../calendula/ui/settings/SettingsUiState.kt | 9 +++ .../ui/settings/SettingsViewModel.kt | 64 +++++++++++++------ app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- .../data/calendar/FakeCalendarDataSource.kt | 12 ++++ .../contacts/SpecialDatesSyncEngineTest.kt | 33 ++++++++++ 9 files changed, 226 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 74e6d86..2f56ffa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import kotlinx.datetime.toJavaLocalDate +import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset import java.util.UUID @@ -133,6 +134,19 @@ interface CalendarDataSource { */ fun updateManagedFields(eventId: Long, values: Map) + /** + * Replace the reminders on **every** event in [calendarId] with [minutes] + * (all-day lead times), each encoded from its own date so it fires at + * [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder + * change applies to existing events too, not just future ones — the managed + * calendars treat reminders as a calendar-level setting. + */ + fun applyManagedCalendarReminders( + calendarId: Long, + allDayReminderTimeMinutes: Int, + minutes: List, + ) + /** * Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes] * (minutes from local midnight) is the wall-clock time all-day reminders @@ -456,6 +470,32 @@ class AndroidCalendarDataSource @Inject constructor( if (rows == 0) throw WriteFailedException("update managed event id=$eventId") } + override fun applyManagedCalendarReminders( + calendarId: Long, + allDayReminderTimeMinutes: Int, + minutes: List, + ) { + val zone = ZoneId.systemDefault() + resolver.query( + CalendarContract.Events.CONTENT_URI, + arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART), + "${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0", + arrayOf(calendarId.toString()), + null, + )?.use { c -> + while (c.moveToNext()) { + val eventId = c.getLong(0) + // Managed events are all-day: DTSTART is a UTC midnight. + val startDate = Instant.ofEpochMilli(c.getLong(1)) + .atZone(ZoneOffset.UTC).toLocalDate() + val encoded = minutes + .map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) } + .distinct() + reconcileReminders(eventId, encoded) + } + } + } + override fun instances(beginMillis: Long, endMillis: Long): List { ensureObserversRegistered() val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt index 4a4b370..655a7a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -90,6 +90,28 @@ class SpecialDatesSyncEngine @Inject constructor( return SpecialDatesSyncResult.Success } + /** + * Set the reminder default for a type's managed calendar and apply it to + * **all** its existing events too (not just future ones) — for managed + * calendars the reminder is a calendar-level setting. Persists the per-calendar + * all-day override so new events keep matching. No-op if the calendar for + * [type] doesn't exist yet. + */ + suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) { + val calendarId = prefs.specialDatesCalendars.first()[type] ?: return + prefs.setCalendarAllDayReminderOverride(calendarId, override) + val minutes = when (override) { + CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first() + CalendarReminderOverride.None -> emptyList() + is CalendarReminderOverride.Minutes -> override.minutes + } + calendars.applyManagedCalendarReminders( + calendarId = calendarId, + allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(), + minutes = minutes, + ) + } + /** Delete every managed calendar and forget its id — used when the feature is turned off. */ suspend fun teardown() { calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) } 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 9e096fd..4c0b4a3 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 @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.CalendarMonth @@ -180,7 +181,12 @@ fun SettingsScreen( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + NotificationsScreen( + state = state, + viewModel = viewModel, + onBack = { section = null }, + onOpenSpecialDates = { section = SettingsSection.SpecialDates }, + ) } AnimatedVisibility( visible = section == SettingsSection.SpecialDates, @@ -795,6 +801,7 @@ private fun NotificationsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + onOpenSpecialDates: () -> Unit, ) { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( @@ -919,6 +926,25 @@ private fun NotificationsScreen( Column { state.writableCalendars.forEach { calendar -> Spacer(Modifier.height(16.dp)) + // A contact special-dates calendar owns its reminders in + // its own section — link there instead of an override. + if (calendar.id in state.managedCalendarIds) { + GroupedRow( + title = calendar.displayName, + summary = stringResource(R.string.settings_calendar_reminders_managed_hint), + position = Position.Alone, + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = onOpenSpecialDates, + ) + return@forEach + } val expanded = calendar.id in expandedCalendars // Calendar card; tapping expands it into a grouped list // of three (the card + the timed and all-day rows). @@ -1074,6 +1100,7 @@ private fun SpecialDatesScreen( var confirmDisableAll by remember { mutableStateOf(false) } var confirmDisableType by remember { mutableStateOf(null) } var editTemplate by remember { mutableStateOf(null) } + var reminderPickerType by remember { mutableStateOf(null) } CollapsingScaffold( title = stringResource(R.string.settings_section_special_dates), @@ -1120,13 +1147,13 @@ private fun SpecialDatesScreen( ) if (state.enabled) { - Spacer(Modifier.height(24.dp)) - // Per-type toggles + their title templates. + // Per-type card: the toggle, and while on, its calendar-wide reminder. SpecialDateType.entries.forEachIndexed { index, type -> + Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp)) val on = type in state.types GroupedRow( title = stringResource(specialDateTypeLabel(type)), - position = if (index == 0) Position.Top else Position.Middle, + position = if (on) Position.Top else Position.Alone, trailing = { Switch( checked = on, @@ -1140,11 +1167,21 @@ private fun SpecialDatesScreen( if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true) }, ) + if (on) { + GroupedRow( + title = stringResource(R.string.settings_special_dates_reminders), + summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])), + position = Position.Bottom, + onClick = { reminderPickerType = type }, + ) + } } + + Spacer(Modifier.height(24.dp)) GroupedRow( title = stringResource(R.string.settings_special_dates_template), summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(), - position = Position.Bottom, + position = Position.Alone, onClick = { editTemplate = SpecialDateType.Birthday }, ) @@ -1202,8 +1239,23 @@ private fun SpecialDatesScreen( onDismiss = { editTemplate = null }, ) } + reminderPickerType?.let { type -> + ReminderDefaultPicker( + title = stringResource(R.string.settings_special_dates_reminders), + presets = ALLDAY_REMINDER_PRESETS, + selected = state.reminderChoices[type] ?: CalendarReminderOverride.None, + // Managed calendars own their reminders outright — no "inherit global". + allowInherit = false, + onSelect = { viewModel.setSpecialDatesReminders(type, it) }, + onDismiss = { reminderPickerType = null }, + ) + } } +/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */ +private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List = + (choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty() + @Composable private fun SpecialDatesDisableDialog( message: String, 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 ad153ce..e516ef7 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.settings +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.ThemeMode @@ -70,6 +71,12 @@ data class SettingsUiState( * colour palette (the colour may then not survive their next sync). */ val allowColorOnUnsupportedCalendars: Boolean = false, + /** + * Ids of the contact special-dates calendars. Their reminders are owned by + * the Contact special dates section, so the per-calendar override list links + * out to it instead of offering an override here. + */ + val managedCalendarIds: Set = emptySet(), ) /** @@ -82,6 +89,8 @@ data class SpecialDatesUiState( val enabled: Boolean = false, val types: Set = SpecialDateType.entries.toSet(), val titleTemplates: Map = emptyMap(), + /** The reminder choice per type's managed calendar (applied to all its events). */ + val reminderChoices: Map = emptyMap(), val showAge: Boolean = true, /** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */ val stalledPermission: Boolean = false, 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 9e80d2e..f029392 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 @@ -97,8 +97,9 @@ class SettingsViewModel @Inject constructor( prefs.perCalendarReminderOverride, prefs.perCalendarAllDayReminderOverride, writableCalendars, - ) { overrides, allDayOverrides, calendars -> - ReminderOverrides(overrides, allDayOverrides, calendars) + prefs.managedCalendarIds, + ) { overrides, allDayOverrides, calendars, managedIds -> + ReminderOverrides(overrides, allDayOverrides, calendars, managedIds) }, combine( prefs.defaultView, @@ -135,6 +136,7 @@ class SettingsViewModel @Inject constructor( perCalendarReminderOverride = overrides.timed, perCalendarAllDayReminderOverride = overrides.allDay, writableCalendars = overrides.calendars, + managedCalendarIds = overrides.managedIds, ) }.stateIn( scope = viewModelScope, @@ -160,6 +162,7 @@ class SettingsViewModel @Inject constructor( val timed: Map>, val allDay: Map>, val calendars: List, + val managedIds: Set, ) private data class ViewSettings( @@ -179,24 +182,39 @@ class SettingsViewModel @Inject constructor( /** 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) + 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, + ) + }, + prefs.specialDatesCalendars, + prefs.perCalendarAllDayReminderOverride, + ) { base, calendars, allDayOverrides -> + base.copy( + reminderChoices = calendars.mapValues { (_, calendarId) -> + // An override is always seeded on creation; present-empty = None. + when (val minutes = allDayOverrides[calendarId]) { + null -> CalendarReminderOverride.Inherit + emptyList() -> CalendarReminderOverride.None + else -> CalendarReminderOverride.Minutes(minutes) + } }, - showAge = showAge, - stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked, - lastRun = status.lastRun, ) }.stateIn( scope = viewModelScope, @@ -245,6 +263,14 @@ class SettingsViewModel @Inject constructor( } } + /** + * Set a type's managed-calendar reminder default and apply it to all its + * existing events (managed calendars own their reminders calendar-wide). + */ + fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) { + viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } } + } + /** Kick off an immediate reconcile (the "Sync now" action). */ fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d8246bb..93dbdf6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -428,13 +428,15 @@ Weitere Tage Titelformat Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist). - Hinweis: {age} ist das Alter zum nächsten Termin beim letzten Sync. Da ein jährlicher Termin nur einen Titel hat, kann es bei weit entfernten Terminen unpassend wirken. + Hinweis: Das Alter ist ein einzelner Wert vom letzten Sync — es wird nicht für jedes Jahr aktualisiert. Jeder jährliche Termin zeigt daher dieselbe Zahl, was bei weiter zurück- oder vorausliegenden Terminen falsch sein kann. + Erinnerungen Alter anzeigen {age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist Jetzt synchronisieren Noch nicht synchronisiert Zuletzt synchronisiert %1$s - Farbe, Sichtbarkeit und Erinnerungen jedes Kalenders legst du in den Kalender-Einstellungen fest. + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4c8f0c..6dbd4b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,14 +357,16 @@ Other dates Title format Use {name} for the contact and {age} for their age (hidden when the year is unknown). - Note: {age} is the age at the next date as of the last sync. Because a yearly event has one title, it can look off on occurrences far in the future. + Note: the age is a single value set at the last sync — it isn\'t updated for each year, so every yearly occurrence shows the same number and it can be wrong for dates further in the past or future. + Reminders 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. + 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 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 edbff7f..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 @@ -190,6 +190,18 @@ internal class FakeCalendarDataSource : CalendarDataSource { 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 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 index c24a411..ad953bc 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -7,6 +7,7 @@ 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 @@ -163,6 +164,38 @@ class SpecialDatesSyncEngineTest { 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(today) + 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(today) + 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") = From 52816b327af405d0a3d2a0b72974ea6cf63ca255 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:54:11 +0200 Subject: [PATCH 13/23] feat(contacts): per-type title format in the special-dates section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Title format" row was only editable for Birthdays; move it into each per-type card so Anniversaries and Other dates get their own editable template too (the dialog was already keyed by type). Each enabled type card now reads toggle → title format → reminders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) 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 4c0b4a3..f5ad659 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 @@ -1147,7 +1147,8 @@ private fun SpecialDatesScreen( ) if (state.enabled) { - // Per-type card: the toggle, and while on, its calendar-wide reminder. + // Per-type card: the toggle, and while on, its title format and its + // calendar-wide reminder. SpecialDateType.entries.forEachIndexed { index, type -> Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp)) val on = type in state.types @@ -1168,6 +1169,12 @@ private fun SpecialDatesScreen( }, ) if (on) { + GroupedRow( + title = stringResource(R.string.settings_special_dates_template), + summary = state.titleTemplates[type].orEmpty(), + position = Position.Middle, + onClick = { editTemplate = type }, + ) GroupedRow( title = stringResource(R.string.settings_special_dates_reminders), summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])), @@ -1177,14 +1184,6 @@ private fun SpecialDatesScreen( } } - Spacer(Modifier.height(24.dp)) - GroupedRow( - title = stringResource(R.string.settings_special_dates_template), - summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(), - position = Position.Alone, - onClick = { editTemplate = SpecialDateType.Birthday }, - ) - Spacer(Modifier.height(24.dp)) GroupedRow( title = stringResource(R.string.settings_special_dates_show_age), From 4e125e58d5cb044d2e44c5e8e22e0facf6e01b13 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 13:19:04 +0200 Subject: [PATCH 14/23] feat(contacts): show source year instead of age in titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 2 +- .../data/contacts/SpecialDatesScheduler.kt | 6 +-- .../data/contacts/SpecialDatesSyncEngine.kt | 27 ++++++-------- .../calendula/data/prefs/SettingsPrefs.kt | 10 ++--- .../domain/contacts/SpecialDateFormatting.kt | 29 ++++----------- .../calendula/ui/settings/SettingsScreen.kt | 20 +++------- .../calendula/ui/settings/SettingsUiState.kt | 3 +- .../ui/settings/SettingsViewModel.kt | 10 ++--- app/src/main/res/values-de/strings.xml | 11 +++--- app/src/main/res/values/strings.xml | 11 +++--- .../contacts/SpecialDatesSyncEngineTest.kt | 37 +++++++++---------- .../calendula/data/prefs/SettingsPrefsTest.kt | 2 +- .../contacts/SpecialDateFormattingTest.kt | 32 +++------------- docs/design/contact-special-dates.md | 14 +++---- 14 files changed, 78 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f6971..e4468d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "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}`, `{age}`). + *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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt index f5237b7..05a1bac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt index 655a7a1..fa3369f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -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, - 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 96d95c3..2f7c87c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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 = 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 = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt index 9c78a36..f356abb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt @@ -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() 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 f5ad659..b0b7fec 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 @@ -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 = { 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 e516ef7..f905a9e 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 @@ -91,7 +91,8 @@ data class SpecialDatesUiState( val titleTemplates: Map = emptyMap(), /** The reminder choice per type's managed calendar (applied to all its events). */ val reminderChoices: Map = 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. */ 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 f029392..8b75a1a 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 @@ -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) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 93dbdf6..882f2e1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -415,8 +415,8 @@ Geburtstage Jahrestage Besondere Tage - {name}s Geburtstag - {name}s Jahrestag + {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 @@ -427,11 +427,10 @@ Jahrestage Weitere Tage Titelformat - Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist). - Hinweis: Das Alter ist ein einzelner Wert vom letzten Sync — es wird nicht für jedes Jahr aktualisiert. Jeder jährliche Termin zeigt daher dieselbe Zahl, was bei weiter zurück- oder vorausliegenden Terminen falsch sein kann. + Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt). Erinnerungen - Alter anzeigen - {age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist + Jahr anzeigen + {year} in Titeln anzeigen, wenn es bekannt ist Jetzt synchronisieren Noch nicht synchronisiert Zuletzt synchronisiert %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6dbd4b6..5abcfb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -356,11 +356,10 @@ Anniversaries Other dates Title format - Use {name} for the contact and {age} for their age (hidden when the year is unknown). - Note: the age is a single value set at the last sync — it isn\'t updated for each year, so every yearly occurrence shows the same number and it can be wrong for dates further in the past or future. + Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown). Reminders - Show age - Include {age} in titles when the birth year is known + Show year + Include {year} in titles when it is known Sync now Not synced yet @@ -500,7 +499,7 @@ Birthdays Anniversaries Special dates - {name}\'s birthday - {name}\'s anniversary + {name}\'s birthday ({year}) + {name}\'s anniversary ({year}) {name} 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 index ad953bc..dbfcff8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -13,15 +13,12 @@ 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 kotlinx.datetime.LocalDate import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.nio.file.Path class SpecialDatesSyncEngineTest { - private val today = LocalDate(2026, 3, 1) - private class FakeContacts( var permission: Boolean = true, var dates: List = emptyList(), @@ -34,7 +31,7 @@ class SpecialDatesSyncEngineTest { 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 ({age})" + SpecialDateType.Birthday -> "{name}'s birthday ({year})" SpecialDateType.Anniversary -> "{name}'s anniversary" SpecialDateType.Custom -> "{name}" } @@ -65,7 +62,7 @@ class SpecialDatesSyncEngineTest { 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(today)).isEqualTo(SpecialDatesSyncResult.Disabled) + assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled) assertThat(calendars.createdManagedCalendars).isEmpty() } @@ -73,7 +70,7 @@ class SpecialDatesSyncEngineTest { 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(today)).isEqualTo(SpecialDatesSyncResult.PermissionMissing) + assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing) assertThat(calendars.createdManagedCalendars).isEmpty() } @@ -88,16 +85,16 @@ class SpecialDatesSyncEngineTest { ) val (engine, settings) = engineWith(tempDir, contacts, calendars) - assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Success) + 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 age; year-known keeps it. + // Year-less contact drops the year; year-known keeps it. val titles = calendars.insertedManagedEvents.map { it.first.title } - assertThat(titles).contains("Jane's birthday (36)") + 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) @@ -109,10 +106,10 @@ class SpecialDatesSyncEngineTest { val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, _) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() val insertsAfterFirst = calendars.insertedManagedEvents.size val calendarsAfterFirst = calendars.createdManagedCalendars.size - engine.sync(today) + engine.sync() assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst) assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst) @@ -125,16 +122,16 @@ class SpecialDatesSyncEngineTest { val calendars = FakeCalendarDataSource() val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, _) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990)) - engine.sync(today) + 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 (36)") + assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)") } @Test @@ -142,10 +139,10 @@ class SpecialDatesSyncEngineTest { val calendars = FakeCalendarDataSource() val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, _) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() contacts.dates = emptyList() - engine.sync(today) + engine.sync() assertThat(calendars.deletedEventIds).hasSize(1) } @@ -154,11 +151,11 @@ class SpecialDatesSyncEngineTest { 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(today) + engine.sync() val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary) settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) - engine.sync(today) + engine.sync() assertThat(calendars.deletedCalendarIds).contains(anniversaryId) assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) @@ -169,7 +166,7 @@ class SpecialDatesSyncEngineTest { val calendars = FakeCalendarDataSource() val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, settings) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440))) @@ -187,7 +184,7 @@ class SpecialDatesSyncEngineTest { fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest { val calendars = FakeCalendarDataSource() val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars) - engine.sync(today) + engine.sync() val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None) 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 f39c5cb..af7a4ac 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 @@ -349,7 +349,7 @@ class SettingsPrefsTest { assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet()) assertThat(prefs.specialDatesCalendars.first()).isEmpty() assertThat(prefs.managedCalendarIds.first()).isEmpty() - assertThat(prefs.specialDatesShowAge.first()).isTrue() + assertThat(prefs.specialDatesShowYear.first()).isTrue() } @Test 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 index 393ccbe..9961f7a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -34,36 +34,14 @@ class SpecialDateFormattingTest { } @Test - fun `age is null when the year is unknown`() { - assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull() + fun `title substitutes name and year`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990)) + .isEqualTo("Jane's birthday (1990)") } @Test - fun `age counts the upcoming birthday when it is still ahead this year`() { - // Born 1990-05-14; today 2026-03-01 → next birthday 2026 → turns 36. - assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 3, 1))).isEqualTo(36) - } - - @Test - fun `age rolls to next year once the birthday has passed`() { - // Born 1990-05-14; today 2026-06-01 → next birthday 2027 → turns 37. - assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 6, 1))).isEqualTo(37) - } - - @Test - fun `age counts today's birthday as this year`() { - assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 5, 14))).isEqualTo(36) - } - - @Test - fun `title substitutes name and age`() { - assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", 30)) - .isEqualTo("Jane's birthday (30)") - } - - @Test - fun `title drops an empty age parenthesis and tidies spacing`() { - assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", null)) + 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 index 2b27c88..e92965d 100644 --- a/docs/design/contact-special-dates.md +++ b/docs/design/contact-special-dates.md @@ -13,14 +13,14 @@ Implementation notes / deviations from this design: (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). -- **Age is a sync-time snapshot.** A `FREQ=YEARLY` row has one static title, so - `{age}` reflects the upcoming birthday at the last sync (refreshed daily / on - foreground), not a per-occurrence value. Because of this, `{age}` is **kept out - of the default templates**; users can add it, and the title-format editor shows - a disclaimer once they do. +- **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 from day one (`{name}`, `{age}`). +- 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 @@ -239,7 +239,7 @@ rest. - [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 + age (when year known; sync-time snapshot). +- [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. From 8e3a17fdc35cd971b766de18bfe72e8e20a2beb5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 22:40:26 +0200 Subject: [PATCH 15/23] refactor(calendar): share the insert values builder + reminder seeding insertManagedEvent duplicated ~45 lines of insertEvent (ContentValues build and the reminder-seeding loop). Both now go through buildEventInsertValues in EventWriteMapper and a shared seedReminders, differing only in the UID they stamp and the colour/attendee columns insertEvent layers on top. Co-Authored-By: Claude Fable 5 --- .../data/calendar/CalendarDataSource.kt | 112 ++++++------------ .../data/calendar/EventWriteMapper.kt | 36 ++++++ 2 files changed, 69 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 2f56ffa..3a8e854 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -416,47 +416,15 @@ class AndroidCalendarDataSource @Inject constructor( } ?: emptyList() override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long { - val times = form.toWriteTimes(ZoneId.systemDefault()) - val values = ContentValues().apply { - put( - CalendarContract.Events.CALENDAR_ID, - requireNotNull(form.calendarId) { "EventForm.calendarId is required" }, - ) - // Deterministic UID = the reconciliation key. A re-sync reads it back - // (queryManagedEvents) and diffs on it, so a contact never doubles. - put(CalendarContract.Events.UID_2445, uid) - put(CalendarContract.Events.TITLE, form.title.trim()) - put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) - put(CalendarContract.Events.DTSTART, times.dtStartMillis) - if (form.rrule == null) { - put(CalendarContract.Events.DTEND, times.dtEndMillis) - } else { - put(CalendarContract.Events.RRULE, form.rrule) - put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay)) - } - put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) - put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) - put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) - form.location.trim().takeIf { it.isNotEmpty() } - ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } - form.description.trim().takeIf { it.isNotEmpty() } - ?.let { put(CalendarContract.Events.DESCRIPTION, it) } - } - val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values) + // Deterministic UID = the reconciliation key. A re-sync reads it back + // (queryManagedEvents) and diffs on it, so a contact never doubles. + val values = buildEventInsertValues(form, uid, form.toWriteTimes(ZoneId.systemDefault())) + val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) ?.let(ContentUris::parseId) ?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}") // Seeded once, then never re-touched by sync — so a user who moves a // birthday reminder keeps their change. - encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes -> - val reminder = ContentValues().apply { - put(CalendarContract.Reminders.EVENT_ID, eventId) - put(CalendarContract.Reminders.MINUTES, minutes) - put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) - } - if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { - Log.w(TAG, "Failed to attach reminder ($minutes min) to managed event $eventId") - } - } + seedReminders(eventId, form, allDayReminderTimeMinutes) return eventId } @@ -779,35 +747,15 @@ class AndroidCalendarDataSource @Inject constructor( .distinct() override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long { - val times = form.toWriteTimes(ZoneId.systemDefault()) - val values = ContentValues().apply { - put( - CalendarContract.Events.CALENDAR_ID, - requireNotNull(form.calendarId) { "EventForm.calendarId is required" }, - ) - // A globally-unique UID so a later .ics backup/restore can identify - // the event and not duplicate it on re-import (the provider leaves - // this null for events it didn't sync). Older rows without one fall - // back to a stable synthesised UID at export time (deriveIcsUid). - put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula") - put(CalendarContract.Events.TITLE, form.title.trim()) - put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) - put(CalendarContract.Events.DTSTART, times.dtStartMillis) - // The provider's invariant: recurring rows carry RRULE+DURATION - // (and no DTEND), one-off rows carry DTEND. - if (form.rrule == null) { - put(CalendarContract.Events.DTEND, times.dtEndMillis) - } else { - put(CalendarContract.Events.RRULE, form.rrule) - put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay)) - } - put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) - put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) - put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) - form.location.trim().takeIf { it.isNotEmpty() } - ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } - form.description.trim().takeIf { it.isNotEmpty() } - ?.let { put(CalendarContract.Events.DESCRIPTION, it) } + // A globally-unique UID so a later .ics backup/restore can identify + // the event and not duplicate it on re-import (the provider leaves + // this null for events it didn't sync). Older rows without one fall + // back to a stable synthesised UID at export time (deriveIcsUid). + val values = buildEventInsertValues( + form, + uid = "${UUID.randomUUID()}@calendula", + times = form.toWriteTimes(ZoneId.systemDefault()), + ).toContentValues().apply { // A null colour just leaves both columns unset (the event inherits // its calendar's colour), so only the key/raw cases are written. when { @@ -819,19 +767,7 @@ class AndroidCalendarDataSource @Inject constructor( val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values) ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") val eventId = ContentUris.parseId(uri) - // Best effort (spec §8): the event exists at this point — a reminder - // that fails to attach is logged, not surfaced as a failed create. - encodedReminders(form, allDayReminderTimeMinutes) - .forEach { minutes -> - val reminder = ContentValues().apply { - put(CalendarContract.Reminders.EVENT_ID, eventId) - put(CalendarContract.Reminders.MINUTES, minutes) - put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) - } - if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { - Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") - } - } + seedReminders(eventId, form, allDayReminderTimeMinutes) // Guests are best-effort like reminders: a row that fails to attach is // logged, not surfaced as a failed create. Calendula never sends an // invitation — it only writes the rows; the backend decides delivery. @@ -839,6 +775,24 @@ class AndroidCalendarDataSource @Inject constructor( return eventId } + /** + * Attach [form]'s reminders to a freshly inserted event. Best effort + * (spec §8): the event exists at this point — a reminder that fails to + * attach is logged, not surfaced as a failed create. + */ + private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) { + encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes -> + val reminder = ContentValues().apply { + put(CalendarContract.Reminders.EVENT_ID, eventId) + put(CalendarContract.Reminders.MINUTES, minutes) + put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) + } + if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { + Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") + } + } + } + override fun updateEvent( eventId: Long, original: EventForm, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 2c3928c..dc830aa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if ( "P${(dtEndMillis - dtStartMillis) / 1_000L}S" } +/** + * Column values for a brand-new Events row: identity, times (the provider's + * invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows + * carry DTEND), availability/access level and trimmed optional text. Shared by + * the user-event and managed-event insert paths, which differ only in the + * [uid] they stamp; colour and attendees are user-event concerns the caller + * layers on top. + */ +internal fun buildEventInsertValues( + form: EventForm, + uid: String, + times: EventWriteTimes, +): Map = buildMap { + put( + CalendarContract.Events.CALENDAR_ID, + requireNotNull(form.calendarId) { "EventForm.calendarId is required" }, + ) + put(CalendarContract.Events.UID_2445, uid) + put(CalendarContract.Events.TITLE, form.title.trim()) + put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, times.dtStartMillis) + if (form.rrule == null) { + put(CalendarContract.Events.DTEND, times.dtEndMillis) + } else { + put(CalendarContract.Events.RRULE, form.rrule) + put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay)) + } + put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) + put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) + put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) + form.location.trim().takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } + form.description.trim().takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Events.DESCRIPTION, it) } +} + /** * Dirty-checked column values for updating an existing Events row: only what * the user actually changed is written, so untouched fields can't stomp From 442697d57c206ce0fa2b5ef52798bdd3d2e78ab9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 22:40:26 +0200 Subject: [PATCH 16/23] refactor(calendar): move the colour palette to data with named entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AndroidSpecialDatesCalendarSpec hardcoded three ARGB literals that its KDoc claimed were 'picked from the shared palette' — a silent-drift hazard, and referencing ui.common from data would invert layering. The palette now lives in data/calendar as CalendarColorPalette with named hues; the swatch pickers use CalendarColorPalette.all and the spec references Purple/Red/Blue. Co-Authored-By: Claude Fable 5 --- .../data/calendar/CalendarColorPalette.kt | 22 +++++++++++++++++++ .../AndroidSpecialDatesCalendarSpec.kt | 7 +++--- .../calendula/ui/calendars/CalendarsScreen.kt | 6 ++--- .../calendula/ui/common/ColorSwatchRow.kt | 13 ----------- .../calendula/ui/edit/EventEditScreen.kt | 4 ++-- 5 files changed, 31 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt new file mode 100644 index 0000000..58b9af1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.calendula.data.calendar + +/** + * Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / + * `EVENT_COLOR`. The named entries exist for callers that need one specific + * hue (the managed special-dates calendars), so they can't drift from the + * swatches offered in the colour picker. + */ +object CalendarColorPalette { + val Red = 0xFFD50000.toInt() + val Orange = 0xFFE67C00.toInt() + val Amber = 0xFFF6BF26.toInt() + val Green = 0xFF33B679.toInt() + val DarkGreen = 0xFF0B8043.toInt() + val Blue = 0xFF039BE5.toInt() + val Indigo = 0xFF3F51B5.toInt() + val Purple = 0xFF8E24AA.toInt() + val Graphite = 0xFF616161.toInt() + + /** The full palette, in swatch-row order. */ + val all: List = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt index cacfd54..0aef3e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.contacts import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import javax.inject.Inject import javax.inject.Singleton @@ -34,8 +35,8 @@ class AndroidSpecialDatesCalendarSpec @Inject constructor( ) override fun color(type: SpecialDateType): Int = when (type) { - SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple - SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red - SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue + SpecialDateType.Birthday -> CalendarColorPalette.Purple + SpecialDateType.Anniversary -> CalendarColorPalette.Red + SpecialDateType.Custom -> CalendarColorPalette.Blue } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 4900a71..9d087c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -94,7 +94,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource -import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE +import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown @@ -142,7 +142,7 @@ fun CalendarsScreen( sessionKey = editorSession, isNew = editorId == NEW_CALENDAR_ID, initialName = editing?.displayName.orEmpty(), - initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(), + initialColor = editing?.color ?: CalendarColorPalette.all.first(), initialDescription = editing?.description.orEmpty(), onSave = { name, color, description -> val id = editorId @@ -507,7 +507,7 @@ private fun CalendarEditor( ) Spacer(Modifier.height(12.dp)) ColorSwatchRow( - colors = CALENDAR_COLOR_PALETTE, + colors = CalendarColorPalette.all, selected = color, onSelect = { color = it }, dark = dark, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt index 6fd11ec..09367db 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt @@ -67,16 +67,3 @@ fun ColorSwatchRow( } } } - -/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */ -val CALENDAR_COLOR_PALETTE: List = listOf( - 0xFFD50000, // red - 0xFFE67C00, // orange - 0xFFF6BF26, // amber - 0xFF33B679, // green - 0xFF0B8043, // dark green - 0xFF039BE5, // blue - 0xFF3F51B5, // indigo - 0xFF8E24AA, // purple - 0xFF616161, // graphite -).map { it.toInt() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 2fd6830..a1906f3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -119,7 +119,7 @@ import de.jeanlucmakiola.calendula.domain.toRRule import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.predictiveBack -import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE +import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.DialogAmountField @@ -1745,7 +1745,7 @@ private fun ColorPickerDialog( ) } else { ColorSwatchRow( - colors = CALENDAR_COLOR_PALETTE, + colors = CalendarColorPalette.all, selected = selected, onSelect = onPickRaw, dark = dark, From b8a420bdab2723fa15c28f64db4ab5ac6592f6b0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 22:40:44 +0200 Subject: [PATCH 17/23] refactor(contacts): one Context.hasContactsPermission() helper The READ_CONTACTS check was written out three times (data source, RootScreen resume trigger, settings screen). A single extension in the contacts package mirrors the existing Context.hasCalendarPermission() precedent; the settings call site follows in the settings cleanup commit. Co-Authored-By: Claude Fable 5 --- .../data/contacts/ContactSpecialDatesDataSource.kt | 9 ++++++--- .../java/de/jeanlucmakiola/calendula/ui/RootScreen.kt | 6 ++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt index c7588d5..13449ed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt @@ -28,6 +28,11 @@ interface ContactSpecialDatesDataSource { fun readSpecialDates(): List } +/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */ +fun Context.hasContactsPermission(): Boolean = + ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) == + PackageManager.PERMISSION_GRANTED + /** Map a `ContactsContract` event `TYPE` to our calendar bucket. */ internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) { Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday @@ -40,9 +45,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( @ApplicationContext private val context: Context, ) : ContactSpecialDatesDataSource { - override fun hasPermission(): Boolean = - ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == - PackageManager.PERMISSION_GRANTED + override fun hasPermission(): Boolean = context.hasContactsPermission() override fun readSpecialDates(): List { if (!hasPermission()) return emptyList() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index d3da90d..3e7a2f3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler +import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel @@ -53,10 +54,7 @@ fun RootScreen( // Refresh the contact special-dates mirror on foreground (the // worker is debounced and no-ops when the feature is off). Gated // on the permission so users without the opt-in never enqueue it. - if (ContextCompat.checkSelfPermission( - context, Manifest.permission.READ_CONTACTS - ) == PackageManager.PERMISSION_GRANTED - ) { + if (context.hasContactsPermission()) { SpecialDatesScheduler.runNow(context, foreground = true) } } From 67a07a93462b9c7a76073cf20b846f85f4532838 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 22:40:44 +0200 Subject: [PATCH 18/23] refactor(settings): dedupe override decoding + on-convention template input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - choiceFor moves next to CalendarReminderOverride in SettingsPrefs and replaces the hand-rolled null/empty/else decoder in SettingsViewModel, so the picker and the special-dates rows can't drift apart. - The special-dates template dialog used the codebase's only OutlinedTextField; it now follows the documented dialog convention (InlineTextField over a tonal surface, see DialogControls). - reminderChoiceLabel drops the manual loop (and its wrong comment — map is inline, so composable calls are fine in its lambda). - The blank-template fallback is shared with the sync engine via resolveTitleTemplate instead of being copy-pasted. Co-Authored-By: Claude Fable 5 --- .../calendula/data/prefs/SettingsPrefs.kt | 12 ++++++ .../calendula/ui/settings/SettingsScreen.kt | 43 +++++++++---------- .../ui/settings/SettingsViewModel.kt | 13 +++--- 3 files changed, 37 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 2f7c87c..d2d2def 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -611,6 +611,18 @@ sealed interface CalendarReminderOverride { data class Minutes(val minutes: List) : CalendarReminderOverride } +/** + * The stored override for [calendarId] in an override map, as a + * [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit], + * empty → [CalendarReminderOverride.None]) — the inverse of how + * [SettingsPrefs.setCalendarReminderOverride] stores a choice. + */ +fun Map>.choiceFor(calendarId: Long): CalendarReminderOverride = when { + !containsKey(calendarId) -> CalendarReminderOverride.Inherit + getValue(calendarId).isEmpty() -> CalendarReminderOverride.None + else -> CalendarReminderOverride.Minutes(getValue(calendarId)) +} + /** * The lead times to prefill on a new event: the matching per-calendar override * if [calendarId] has one for this event kind, otherwise the global default for 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 b0b7fec..2a7d3f8 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 @@ -86,8 +86,10 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.data.prefs.choiceFor import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref @@ -104,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip @@ -1090,7 +1093,7 @@ private fun SpecialDatesScreen( } } val requestOrEnable: () -> Unit = { - if (hasContactsPermission(context)) { + if (context.hasContactsPermission()) { viewModel.setSpecialDatesEnabled(true) } else { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) @@ -1290,12 +1293,21 @@ private fun SpecialDatesTemplateDialog( 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(), - ) + // The app's borderless input over a tonal surface (the dialog + // convention — see DialogControls), not Material's outlined field. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp), + ) { + InlineTextField( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(R.string.settings_special_dates_template), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) + } Spacer(Modifier.height(8.dp)) Text( text = stringResource(R.string.settings_special_dates_template_hint), @@ -1324,10 +1336,6 @@ private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) { 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) { @@ -1426,22 +1434,11 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String { return DateFormat.getTimeFormat(context).format(time) } -/** The stored override for [calendarId], as a picker choice (absent → inherit). */ -private fun Map>.choiceFor(calendarId: Long): CalendarReminderOverride = when { - !containsKey(calendarId) -> CalendarReminderOverride.Inherit - this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None - else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)) -} - /** Label for a global-default choice: empty → "None", else the lead times joined. */ @Composable private fun reminderChoiceLabel(minutes: List): String { if (minutes.isEmpty()) return stringResource(R.string.reminder_none) - // reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather - // than a (non-composable) joinToString transform. - val parts = ArrayList(minutes.size) - for (m in minutes) parts.add(reminderLeadTimeLabel(m)) - return parts.joinToString(", ") + return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ") } /** Row summary for a calendar: its override, or the inherited global default. */ 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 8b75a1a..a29f814 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 @@ -13,8 +13,10 @@ 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.contacts.resolveTitleTemplate import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.data.prefs.choiceFor import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason @@ -195,8 +197,7 @@ class SettingsViewModel @Inject constructor( // 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) + specialDatesSpec.resolveTitleTemplate(type, templates) }, showYear = showYear, stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked, @@ -207,13 +208,9 @@ class SettingsViewModel @Inject constructor( prefs.perCalendarAllDayReminderOverride, ) { base, calendars, allDayOverrides -> base.copy( + // An override is always seeded on creation; present-empty = None. reminderChoices = calendars.mapValues { (_, calendarId) -> - // An override is always seeded on creation; present-empty = None. - when (val minutes = allDayOverrides[calendarId]) { - null -> CalendarReminderOverride.Inherit - emptyList() -> CalendarReminderOverride.None - else -> CalendarReminderOverride.Minutes(minutes) - } + allDayOverrides.choiceFor(calendarId) }, ) }.stateIn( From 86d4e115848f0d1367063c114c68f5e651603dfe Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 22:40:44 +0200 Subject: [PATCH 19/23] refactor(contacts): simplify the sync engine's gates and context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The worker no longer pre-checks specialDatesEnabled: the engine already gates on it, and the worker now consumes the previously-unreachable Disabled result instead (without recording a run). - ReminderContext drops the timed defaults it could never use — managed events are always all-day — saving two DataStore reads per sync. - createCalendar no longer persists the calendar id itself; reconcileCalendars is the single place ids are recorded (it always followed up with the same write). - The title-template fallback uses the shared resolveTitleTemplate. Co-Authored-By: Claude Fable 5 --- .../data/contacts/SpecialDatesScheduler.kt | 21 ++++++------ .../data/contacts/SpecialDatesSyncEngine.kt | 32 +++++++++---------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt index 05a1bac..a906f9b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt @@ -83,9 +83,6 @@ class SpecialDatesSyncWorker( override suspend fun doWork(): Result { val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) val prefs = deps.settingsPrefs() - // Respect the toggle even for already-queued work; never revive a - // disabled feature. - if (!prefs.specialDatesEnabled.first()) return Result.success() val now = System.currentTimeMillis() // A foreground resume can fire often — skip if we synced recently. @@ -95,13 +92,19 @@ class SpecialDatesSyncWorker( } return try { - val result = deps.syncEngine().sync() - val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) { - SpecialDatesStalledReason.PermissionRevoked - } else { - null + when (val result = deps.syncEngine().sync()) { + // The engine re-checks the toggle, so already-queued work never + // revives a disabled feature; don't record a run either. + SpecialDatesSyncResult.Disabled -> return Result.success() + else -> { + val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) { + SpecialDatesStalledReason.PermissionRevoked + } else { + null + } + prefs.recordSpecialDatesRun(now, stalled) + } } - prefs.recordSpecialDatesRun(now, stalled) if (foreground) prefs.setSpecialDatesLastForegroundSync(now) Result.success() } catch (e: Exception) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt index fa3369f..082d3bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -6,7 +6,6 @@ import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -39,6 +38,16 @@ interface SpecialDatesCalendarSpec { fun defaultTitleTemplate(type: SpecialDateType): String } +/** + * The stored title template for [type], falling back to the localized default + * when blank/absent — the one resolution rule shared by the sync (what gets + * written) and the settings editor (what gets shown). + */ +fun SpecialDatesCalendarSpec.resolveTitleTemplate( + type: SpecialDateType, + stored: Map, +): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type) + /** * Mirrors contact birthdays/anniversaries/custom dates into local calendars, * one per type. Every reconcile is an idempotent diff keyed on the deterministic @@ -72,8 +81,7 @@ class SpecialDatesSyncEngine @Inject constructor( val showYear = prefs.specialDatesShowYear.first() calendarByType.forEach { (type, calendarId) -> - val template = templates[type]?.takeIf { it.isNotBlank() } - ?: spec.defaultTitleTemplate(type) + val template = spec.resolveTitleTemplate(type, templates) syncType( calendarId = calendarId, type = type, @@ -143,8 +151,8 @@ class SpecialDatesSyncEngine @Inject constructor( } private suspend fun createCalendar(type: SpecialDateType): Long { + // reconcileCalendars persists the id — the single place ids are recorded. val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type) - prefs.setSpecialDatesCalendarId(type, id) // Seed a useful all-day reminder default (on the day + a week before), so // birthdays get lead time out of the box. Per-calendar, user-adjustable — // only set when the user hasn't already configured this calendar. @@ -212,29 +220,21 @@ class SpecialDatesSyncEngine @Inject constructor( ) } + // Managed events are always all-day, so only the all-day defaults apply. private suspend fun readReminderContext(): ReminderContext = ReminderContext( - timedGlobal = prefs.defaultReminderMinutes.first(), allDayGlobal = prefs.defaultAllDayReminderMinutes.first(), - timedOverrides = prefs.perCalendarReminderOverride.first(), allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(), allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(), ) private data class ReminderContext( - val timedGlobal: List, val allDayGlobal: List, - val timedOverrides: Map>, val allDayOverrides: Map>, val allDayTimeMinutes: Int, ) { - fun resolveAllDay(calendarId: Long): List = resolveDefaultReminder( - timedGlobal = timedGlobal, - allDayGlobal = allDayGlobal, - timedOverrides = timedOverrides, - allDayOverrides = allDayOverrides, - calendarId = calendarId, - isAllDay = true, - ) + /** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */ + fun resolveAllDay(calendarId: Long): List = + allDayOverrides[calendarId] ?: allDayGlobal } private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm) From 9f7e93d5f83cc0910b024cbf02c0f8193d781d1b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 23:03:33 +0200 Subject: [PATCH 20/23] fix(contacts): seal managed calendars off from user edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed special-dates calendars were only recognised by an id cached in preferences, and were offered as ordinary write targets — so a user could create an event in 'Birthdays' (which the next sync then deleted), and after a backup restore wiped the prefs the editor lock silently disappeared. CalendarSource now carries isManaged, read from the durable CAL_SYNC2 marker the data layer already stamps, so identity survives a restore. With it: - the new-event calendar picker excludes managed calendars (no user events land in a calendar the sync owns); - the editor lock keys off the marker, not a stored id; - a managed event's save is forced to the whole series instead of parking in the scope dialog (a 'this/following' split creates rows the sync reverts); - the detail sheet hides Delete for managed events (the sync would just resurrect them — a contact date is removed at its source). Co-Authored-By: Claude Fable 5 --- .../calendula/data/calendar/CalendarMapper.kt | 7 ++++ .../calendula/data/calendar/Projections.kt | 9 +++++ .../jeanlucmakiola/calendula/domain/Models.kt | 8 +++++ .../calendula/ui/detail/EventDetailScreen.kt | 20 ++++++----- .../calendula/ui/detail/EventDetailUiState.kt | 6 ++++ .../ui/detail/EventDetailViewModel.kt | 1 + .../calendula/ui/edit/EventEditViewModel.kt | 36 ++++++++++++++----- 7 files changed, 70 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt index dda3126..c4d214a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt @@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource { } else { null }, + // A special-dates mirror calendar, recognised by its durable CAL_SYNC2 + // marker (only meaningful on the local calendars the app owns). This is + // the source of truth for the editor lock — independent of any stored + // preference that a backup restore could have wiped. + isManaged = isLocal && + getString(CalendarProjection.IDX_MANAGED_MARKER) + ?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 5a362bf..2066ac7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -15,9 +15,17 @@ internal object CalendarProjection { // own we stash one in CAL_SYNC1 (synced rows put their sync token here, // so the mapper only reads it for local calendars). DESCRIPTION_COLUMN, + // The special-dates marker (CAL_SYNC2) — the durable identity the app + // uses to recognise its own managed calendars, independent of any + // stored preference id (which a backup restore / data wipe can lose). + MANAGED_MARKER_COLUMN, ) const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1 + const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2 + + /** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */ + const val MANAGED_MARKER_PREFIX = "calendula.specialdates" const val IDX_ID = 0 const val IDX_DISPLAY_NAME = 1 @@ -27,6 +35,7 @@ internal object CalendarProjection { const val IDX_VISIBLE = 5 const val IDX_ACCESS_LEVEL = 6 const val IDX_DESCRIPTION = 7 + const val IDX_MANAGED_MARKER = 8 } internal object InstanceProjection { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 5d5b89f..aaf4fb5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -26,6 +26,14 @@ data class CalendarSource( * owns for its own calendars). Always null for synced calendars. */ val description: String? = null, + /** + * A special-dates mirror calendar the app manages (birthdays/anniversaries + * from contacts). Its events' title/date/recurrence are owned by the sync, + * so it's hidden from the new-event calendar picker and its events lock those + * fields in the editor. Recognised by a durable provider marker, so it holds + * even after a backup restore clears the app's stored ids. + */ + val isManaged: Boolean = false, ) data class EventInstance( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index c187ac3..eefce33 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -250,14 +250,18 @@ fun EventDetailScreen( contentDescription = stringResource(R.string.event_detail_edit), ) } - IconButton( - onClick = onDeleteClick, - enabled = deleteState != DeleteUiState.Deleting, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.event_detail_delete), - ) + // Managed special-dates events have no delete — the sync + // would resurrect them; the date is removed at the contact. + if (!s.isManaged) { + IconButton( + onClick = onDeleteClick, + enabled = deleteState != DeleteUiState.Deleting, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.event_detail_delete), + ) + } } } }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt index 60d405b..6f6dd97 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt @@ -15,6 +15,12 @@ sealed interface EventDetailUiState { val calendarName: String?, /** Whether the owning calendar allows modifying events (shows edit/delete). */ val canModify: Boolean = false, + /** + * The event belongs to a managed special-dates calendar: its editable + * fields (reminders, notes) can still be edited, but delete is hidden — + * the sync would just resurrect it. Contact dates are removed at the source. + */ + val isManaged: Boolean = false, ) : EventDetailUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 40f7ce8..8374c21 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor( detail = corrected, calendarName = calendar?.displayName, canModify = calendar?.canModifyContents == true, + isManaged = calendar?.isManaged == true, ) } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 673501a..fe1ec73 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor( val autofocusTitle: Boolean, ) + /** Every calendar the provider exposes; the source for both lists below. */ + private val allCalendars: Flow> = + repository.calendars().catch { emit(emptyList()) } + /** * Writable calendars — the only valid event targets. Disabled calendars are * excluded, so you can't create into a calendar you've removed from the app; * a last-used preselect landing on a now-disabled calendar falls back to the * first remaining writable one (handled by [resolvedCalendarId] and [state]). + * Managed special-dates calendars are excluded too: their events are owned by + * the contact sync, which would delete any user event created there. */ private val writableCalendars: Flow> = combine( - repository.calendars(), + allCalendars, prefs.disabledCalendarIds, ) { calendars, disabled -> - calendars.filter { it.canModifyContents && it.id !in disabled } - }.catch { emit(emptyList()) } + calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged } + } /** The target calendar id, resolved exactly as the form shows it. */ private val resolvedCalendarId: Flow = combine( @@ -165,24 +171,33 @@ class EventEditViewModel @Inject constructor( ::ExternalInputs, ).flowOn(io), colorPalette, - settingsPrefs.managedCalendarIds, - ) { local, external, palette, managedIds -> + allCalendars, + ) { local, external, palette, allCalendars -> val form = local.form ?: return@combine null val resolvedId = form.calendarId ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } ?: external.writable.firstOrNull()?.id val resolved = form.copy(calendarId = resolvedId) + val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId } + // Editing an event in a managed calendar locks its synced fields. Keyed + // off the calendar's durable marker, not a stored id, so it holds after a + // backup restore too. + val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true + // The picker offers writable calendars only; when editing a managed event + // its own (excluded) calendar is added back so the row still names it. + val pickerCalendars = + if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar + else external.writable val visibleFields = external.defaultFields + local.revealed EventEditUiState( form = resolved, - calendars = external.writable, + calendars = pickerCalendars, problems = if (local.showProblems) resolved.problems() else emptySet(), saveState = local.saveState, visibleFields = visibleFields, hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), isEditing = local.editTarget != null, - // Editing an event in a managed calendar locks its synced fields. - isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds, + isManaged = isManaged, autofocusTitle = external.autofocusTitle, // A modified-occurrence exception can't carry its own rule, so // the scope dialog drops "only this event" after a rule change. @@ -420,7 +435,10 @@ class EventEditViewModel @Inject constructor( _saveState.value = SaveUiState.Saved return } - if (target != null && target.original.rrule != null) { + // Managed events are a yearly series whose editable fields (reminders, + // notes, location) live on the series row — never offer the scope dialog, + // which would split the series into an exception the sync then reverts. + if (target != null && target.original.rrule != null && !current.isManaged) { _saveState.value = SaveUiState.AwaitingScope return } From 8881559e67b3cf5f36c921e07ec622b14c5207b9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 23:03:51 +0200 Subject: [PATCH 21/23] fix(contacts): scope managed-event queries + fix reminder fire hour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two provider-level bugs in the managed-event data path: - queryManagedEvents and applyManagedCalendarReminders matched every event in the calendar (UID_2445 IS NOT NULL / no filter). A stray user event there (e.g. an .ics import) was treated as 'existing but not desired' and deleted, or had its own reminders wiped and all-day-re-encoded. Both now match only our own mirror events (the 'contact-' UID prefix). - All-day reminder offsets were sampled at the event's DTSTART, which for a year-less birthday is the 1972 leap anchor — a year whose timezone offset (pre-DST) differs from today's, skewing every modern occurrence by up to an hour. The offset is now sampled at the upcoming occurrence (nextYearlyOccurrence), leaving only the inherent ±1h DST drift. Co-Authored-By: Claude Fable 5 --- .../data/calendar/AllDayReminderEncoding.kt | 21 +++++ .../data/calendar/CalendarDataSource.kt | 89 +++++++++++++------ .../calendar/AllDayReminderEncodingTest.kt | 37 ++++++++ 3 files changed, 122 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt index 72afc6a..446489c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt @@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes( return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt() } +/** + * The next date on or after [today] carrying [month]/[day] — the date to sample + * a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an + * ancient anchor (1972 for year-less dates), a year whose timezone rules + * (pre-DST offsets) differ from today's; sampling the offset there skews every + * modern occurrence by hours. Sampling at the upcoming occurrence makes the + * stored offset correct for it and its neighbours (only ±1h DST drift remains, + * the inherent limit). Feb-29 skips forward to the next leap year. + */ +internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate { + var year = today.year + // A Feb-29 date is valid only every ~4 years; 8 tries always reaches one. + repeat(8) { + val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull() + if (candidate != null && !candidate.isBefore(today)) return candidate + year++ + } + // Unreachable for real month/day inputs; fall back to the raw anchor. + return LocalDate.of(today.year, month, day) +} + /** * Recover the semantic whole-day lead time from a raw all-day reminder * [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 3a8e854..0c01ef2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -24,12 +24,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.Reminder +import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import kotlinx.datetime.toJavaLocalDate import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.time.ZoneOffset import java.util.UUID @@ -135,11 +137,12 @@ interface CalendarDataSource { fun updateManagedFields(eventId: Long, values: Map) /** - * Replace the reminders on **every** event in [calendarId] with [minutes] - * (all-day lead times), each encoded from its own date so it fires at - * [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder - * change applies to existing events too, not just future ones — the managed - * calendars treat reminders as a calendar-level setting. + * Replace the reminders on **every managed** event in [calendarId] with + * [minutes] (all-day lead times), each encoded so it fires at + * [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the + * special-dates section so a reminder change applies to existing events too, + * not just future ones — the managed calendars treat reminders as a + * calendar-level setting. Skips any non-managed (user) event in the calendar. */ fun applyManagedCalendarReminders( calendarId: Long, @@ -393,10 +396,13 @@ class AndroidCalendarDataSource @Inject constructor( CalendarContract.Events.DTSTART, CalendarContract.Events.RRULE, ), + // Only our own mirror events (UID prefix), so a user event that somehow + // landed in this calendar (e.g. an .ics import) is never seen as + // "existing but not desired" and deleted by the diff. "${CalendarContract.Events.CALENDAR_ID} = ? AND " + "${CalendarContract.Events.DELETED} = 0 AND " + - "${CalendarContract.Events.UID_2445} IS NOT NULL", - arrayOf(calendarId.toString()), + "${CalendarContract.Events.UID_2445} LIKE ?", + arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"), null, )?.use { c -> buildList { @@ -418,13 +424,19 @@ class AndroidCalendarDataSource @Inject constructor( override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long { // Deterministic UID = the reconciliation key. A re-sync reads it back // (queryManagedEvents) and diffs on it, so a contact never doubles. - val values = buildEventInsertValues(form, uid, form.toWriteTimes(ZoneId.systemDefault())) + val times = form.toWriteTimes(ZoneId.systemDefault()) + val values = buildEventInsertValues(form, uid, times) val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) ?.let(ContentUris::parseId) ?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}") // Seeded once, then never re-touched by sync — so a user who moves a - // birthday reminder keeps their change. - seedReminders(eventId, form, allDayReminderTimeMinutes) + // birthday reminder keeps their change. Encoded against the upcoming + // occurrence, not the ancient DTSTART anchor (wrong timezone offset). + val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate() + insertReminderRows( + eventId, + managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes), + ) return eventId } @@ -443,23 +455,26 @@ class AndroidCalendarDataSource @Inject constructor( allDayReminderTimeMinutes: Int, minutes: List, ) { - val zone = ZoneId.systemDefault() resolver.query( CalendarContract.Events.CONTENT_URI, arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART), - "${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0", - arrayOf(calendarId.toString()), + // Only our own mirror events, so a stray user event in this calendar + // isn't all-day-re-encoded or stripped of its own reminders. + "${CalendarContract.Events.CALENDAR_ID} = ? AND " + + "${CalendarContract.Events.DELETED} = 0 AND " + + "${CalendarContract.Events.UID_2445} LIKE ?", + arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"), null, )?.use { c -> while (c.moveToNext()) { val eventId = c.getLong(0) - // Managed events are all-day: DTSTART is a UTC midnight. - val startDate = Instant.ofEpochMilli(c.getLong(1)) + // Managed events are all-day: DTSTART is a UTC midnight anchor. + val anchor = Instant.ofEpochMilli(c.getLong(1)) .atZone(ZoneOffset.UTC).toLocalDate() - val encoded = minutes - .map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) } - .distinct() - reconcileReminders(eventId, encoded) + reconcileReminders( + eventId, + managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes), + ) } } } @@ -780,8 +795,12 @@ class AndroidCalendarDataSource @Inject constructor( * (spec §8): the event exists at this point — a reminder that fails to * attach is logged, not surfaced as a failed create. */ - private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) { - encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes -> + private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) = + insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes)) + + /** Insert one `METHOD_ALERT` reminder row per raw provider offset. */ + private fun insertReminderRows(eventId: Long, encodedMinutes: List) { + encodedMinutes.forEach { minutes -> val reminder = ContentValues().apply { put(CalendarContract.Reminders.EVENT_ID, eventId) put(CalendarContract.Reminders.MINUTES, minutes) @@ -793,6 +812,25 @@ class AndroidCalendarDataSource @Inject constructor( } } + /** + * A managed all-day event's reminders, encoded so they fire at + * [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's + * DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using + * it directly would sample a stale timezone offset, so the offset is sampled + * at the next occurrence of the anchor's month/day instead. + */ + private fun managedAllDayReminderMinutes( + anchor: LocalDate, + reminders: List, + allDayReminderTimeMinutes: Int, + ): List { + val zone = ZoneId.systemDefault() + val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone)) + return reminders + .map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) } + .distinct() + } + override fun updateEvent( eventId: Long, original: EventForm, @@ -1217,11 +1255,12 @@ class AndroidCalendarDataSource @Inject constructor( const val LOCAL_ACCOUNT_NAME = "Calendula" /** - * Column and namespace for the special-dates marker. CAL_SYNC1 already - * holds the local-calendar description, so the marker uses CAL_SYNC2. + * Column and namespace for the special-dates marker (shared with the + * calendar projection, which reads it into [CalendarSource.isManaged]). + * CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2. */ - val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2 - const val MANAGED_MARKER_PREFIX = "calendula.specialdates" + val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN + const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX /** * How far ahead/behind a search looks for a recurring event's nearest 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 From 7fe59b36c310290f039a5e34aa0f40b65270b2cf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 23:04:06 +0200 Subject: [PATCH 22/23] fix(contacts): keep distinct custom dates instead of collapsing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror keyed each event on (contact, type) only, and the read deduped on that same pair from an unordered query. So a contact with two custom dates ('Wedding', 'Graduation') mirrored just one — and, the winner being order-dependent, the single event could ping-pong between the two dates across syncs. A Custom date's UID now carries a discriminator (its label, else its month-day), the read dedups on the full UID, and the query is ordered by Data._ID so a genuine duplicate resolves the same way every time. Co-Authored-By: Claude Fable 5 --- .../contacts/ContactSpecialDatesDataSource.kt | 16 +++++--- .../domain/contacts/SpecialDateFormatting.kt | 38 ++++++++++++++++--- .../contacts/SpecialDateFormattingTest.kt | 31 +++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt index 13449ed..f4edca0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt @@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import de.jeanlucmakiola.calendula.domain.contacts.managedUid import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate import javax.inject.Inject import javax.inject.Singleton @@ -50,9 +51,12 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( override fun readSpecialDates(): List { if (!hasPermission()) return emptyList() val resolver = context.contentResolver - // A contact can carry the same date more than once (multiple raw - // contacts under one aggregate); keep the first per (contact, type). - val seen = HashSet>() + // A contact can carry the same date more than once (multiple raw contacts + // under one aggregate); dedup on the mirror's reconciliation key so exact + // duplicates collapse while genuinely distinct dates (two custom events on + // one contact) are all kept. Ordered by Data._ID so which of two truly + // conflicting rows wins is stable across syncs (no event ping-pong). + val seen = HashSet() val result = ArrayList() runCatching { resolver.query( @@ -60,7 +64,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( PROJECTION, "${ContactsContract.Data.MIMETYPE} = ?", arrayOf(Event.CONTENT_ITEM_TYPE), - null, + "${ContactsContract.Data._ID} ASC", )?.use { c -> val idxDate = c.getColumnIndexOrThrow(Event.START_DATE) val idxType = c.getColumnIndexOrThrow(Event.TYPE) @@ -71,8 +75,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue val parts = parseContactEventDate(c.getString(idxDate)) ?: continue val type = specialDateTypeForRawEventType(c.getInt(idxType)) - if (!seen.add(lookup to type)) continue - result += ContactSpecialDate( + val date = ContactSpecialDate( lookupKey = lookup, displayName = c.getString(idxName)?.trim().orEmpty(), type = type, @@ -81,6 +84,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( year = parts.year, label = c.getString(idxLabel)?.takeIf { it.isNotBlank() }, ) + if (seen.add(date.managedUid())) result += date } } }.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt index f356abb..393ccf8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt @@ -3,12 +3,40 @@ package de.jeanlucmakiola.calendula.domain.contacts import kotlinx.datetime.LocalDate /** - * The deterministic `Events.UID_2445` that ties a mirrored event to its source - * contact date. Stable across syncs (the reconciliation key), and namespaced by - * type so the same contact's birthday and anniversary never collide. + * Prefix of every managed-event `UID_2445`. Distinguishes mirror events from + * user-created ones (which carry a random `@calendula` UID), so the sync + * only ever reconciles — and never deletes — events it actually owns. */ -fun managedEventUid(type: SpecialDateType, lookupKey: String): String = - "contact-${type.name.lowercase()}:$lookupKey@calendula" +const val MANAGED_UID_PREFIX = "contact-" + +/** + * The deterministic `Events.UID_2445` that ties a mirrored event to its source + * contact date. Stable across syncs (the reconciliation key), namespaced by + * type so the same contact's birthday and anniversary never collide, and — when + * a [discriminator] is given — by it too, so two Custom dates on one contact + * (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering + * each other. + */ +fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String { + val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty() + return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula" +} + +/** + * The reconciliation key for this date's mirrored event. Birthdays/anniversaries + * are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom] + * date adds a discriminator (its label, else its month-day) so distinct custom + * dates on one contact don't collapse into a single event. + */ +fun ContactSpecialDate.managedUid(): String = + managedEventUid(type, lookupKey, customDiscriminator()) + +private fun ContactSpecialDate.customDiscriminator(): String? = + if (type == SpecialDateType.Custom) { + label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day) + } else { + null + } /** * The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real 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 index 9961f7a..47bcb83 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -23,6 +23,37 @@ class SpecialDateFormattingTest { .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)) From 84e6402031fdcfa2edf1125df1ea96d5df10806a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 23:04:26 +0200 Subject: [PATCH 23/23] fix(contacts): serialize sync/teardown and stop retrying on lost permission - The periodic job and the immediate 'Sync now'/foreground runs had no mutual exclusion, so two overlapping reconciles could each see no managed calendar and both create one (duplicate 'Birthdays', doubled events); likewise a disable-teardown racing an in-flight sync got its calendars recreated right after deletion. A lifecycle Mutex now makes sync() and teardown() atomic, and sync() re-reads the enabled flag inside the lock so a teardown always wins. - The foreground resume trigger shared a unique work name with enable/'Sync now' under ExistingWorkPolicy.REPLACE, so a debounced foreground enqueue could cancel-and-swallow a pending enable sync (feature on, no calendars for up to a day). It now uses its own work name. - doWork() mapped every exception to retry(), so a revoked calendar permission retried with backoff forever and never surfaced. A SecurityException now parks the feature in the stalled state instead. Co-Authored-By: Claude Fable 5 --- .../data/contacts/SpecialDatesScheduler.kt | 19 +++++++++++++++--- .../data/contacts/SpecialDatesSyncEngine.kt | 20 ++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt index a906f9b..ba5cff5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt @@ -30,6 +30,7 @@ object SpecialDatesScheduler { private const val WORK_NAME = "special-dates-sync" private const val WORK_NAME_NOW = "special-dates-sync-now" + private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground" private const val KEY_FOREGROUND = "foreground" /** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */ @@ -38,6 +39,7 @@ object SpecialDatesScheduler { if (!enabled) { workManager.cancelUniqueWork(WORK_NAME) workManager.cancelUniqueWork(WORK_NAME_NOW) + workManager.cancelUniqueWork(WORK_NAME_FOREGROUND) return } val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) @@ -48,15 +50,19 @@ object SpecialDatesScheduler { } /** - * Run one reconcile immediately. [foreground] runs are debounced (the app can - * resume often); enable/"Sync now" runs ([foreground] false) always sync. + * Run one reconcile immediately. [foreground] runs (the app resuming) are + * debounced inside the worker and use their own work name, so a frequent + * foreground resync can never REPLACE — and swallow — a pending enable / + * "Sync now" run, which always syncs. The engine serializes the two if they + * overlap. */ fun runNow(context: Context, foreground: Boolean = false) { val request = OneTimeWorkRequestBuilder() .setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build()) .build() + val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW WorkManager.getInstance(context) - .enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request) + .enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request) } internal const val INPUT_FOREGROUND = KEY_FOREGROUND @@ -107,6 +113,13 @@ class SpecialDatesSyncWorker( } if (foreground) prefs.setSpecialDatesLastForegroundSync(now) Result.success() + } catch (e: SecurityException) { + // A revoked calendar/contacts permission won't fix itself on retry — + // park the feature in a stalled state (surfaced in settings) instead + // of retrying with backoff forever. + Log.w(TAG, "Special-dates sync lacks a required permission", e) + prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked) + Result.success() } catch (e: Exception) { Log.w(TAG, "Special-dates sync failed", e) Result.retry() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt index 082d3bc..74ba06a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -13,10 +13,12 @@ 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.managedEventUid +import de.jeanlucmakiola.calendula.domain.contacts.managedUid import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle import de.jeanlucmakiola.calendula.domain.toRRule import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import java.time.ZoneId @@ -64,11 +66,19 @@ class SpecialDatesSyncEngine @Inject constructor( private val spec: SpecialDatesCalendarSpec, ) { + // Serializes calendar-lifecycle work: two overlapping syncs (the daily job + // racing a "Sync now"/foreground run) would each see no managed calendar and + // both create one; a teardown racing an in-flight sync would delete calendars + // the sync then recreates. Holding this for the whole of sync()/teardown() + // makes those check-then-act sequences atomic, and — because sync() re-reads + // the enabled flag inside the lock — a teardown always wins the race. + private val lifecycleMutex = Mutex() + /** * Reconcile every enabled type against the device's contacts. Returns why it * stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success]. */ - suspend fun sync(): SpecialDatesSyncResult { + suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock { if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing @@ -91,7 +101,7 @@ class SpecialDatesSyncEngine @Inject constructor( reminderCtx = reminderCtx, ) } - return SpecialDatesSyncResult.Success + SpecialDatesSyncResult.Success } /** @@ -117,7 +127,7 @@ class SpecialDatesSyncEngine @Inject constructor( } /** Delete every managed calendar and forget its id — used when the feature is turned off. */ - suspend fun teardown() { + suspend fun teardown() = lifecycleMutex.withLock { calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) } SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) } } @@ -211,7 +221,7 @@ class SpecialDatesSyncEngine @Inject constructor( val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis return BuiltManagedEvent( desired = ManagedEventDesired( - uid = managedEventUid(type, sd.lookupKey), + uid = sd.managedUid(), title = title, dtStartMillis = dtStartMillis, rrule = YEARLY_RRULE,