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. -- 2.49.1 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") + } +} -- 2.49.1 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) -- 2.49.1 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 -- 2.49.1 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 -- 2.49.1 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. -- 2.49.1 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. -- 2.49.1 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}`). -- 2.49.1 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") = -- 2.49.1 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), -- 2.49.1 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. -- 2.49.1 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 -- 2.49.1 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, -- 2.49.1 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) } } -- 2.49.1 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( -- 2.49.1 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) -- 2.49.1 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 } -- 2.49.1 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 -- 2.49.1 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)) -- 2.49.1 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, -- 2.49.1