Compare commits
11 Commits
feat/per-c
...
030c3e6ce1
| Author | SHA1 | Date | |
|---|---|---|---|
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
| 25ff7ce7ca |
12
CHANGELOG.md
12
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
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!--
|
||||
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||
contacts, and only after the user enables it and grants this at runtime
|
||||
(never requested at startup). Everything stays offline — birthdays and
|
||||
other contact dates are mirrored one-way into local calendars; contacts
|
||||
are never written and nothing leaves the device (the app has no INTERNET
|
||||
permission). See docs/design/contact-special-dates.md.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<!--
|
||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
@@ -97,6 +98,41 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||
*/
|
||||
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||
|
||||
/**
|
||||
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||
* detect when the user deleted one in system settings.
|
||||
*/
|
||||
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||
|
||||
/**
|
||||
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||
*/
|
||||
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||
|
||||
/**
|
||||
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||
|
||||
/**
|
||||
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||
*/
|
||||
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||
|
||||
/**
|
||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||
@@ -292,6 +328,134 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||
}
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.NAME, name)
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||
put(
|
||||
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||
)
|
||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
// The marker that identifies this as our special-dates calendar,
|
||||
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||
// holds the description, so the marker lives in CAL_SYNC2.
|
||||
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||
}
|
||||
val uri = resolver.insert(localCalendarsUri(), values)
|
||||
?: throw WriteFailedException("create managed calendar")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
|
||||
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
|
||||
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
|
||||
"$MANAGED_MARKER_COLUMN IS NOT NULL",
|
||||
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val type = typeForMarker(c.getString(1)) ?: continue
|
||||
add(ManagedCalendarRow(id = c.getLong(0), type = type))
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.RRULE,
|
||||
),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
add(
|
||||
ManagedEventRow(
|
||||
eventId = c.getLong(0),
|
||||
uid = uid,
|
||||
title = c.getString(2).orEmpty(),
|
||||
dtStartMillis = c.getLong(3),
|
||||
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: 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)
|
||||
?.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")
|
||||
}
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||
if (values.isEmpty()) return
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(),
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||
}
|
||||
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
@@ -1038,6 +1202,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||
}
|
||||
|
||||
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
|
||||
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
|
||||
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
|
||||
}
|
||||
|
||||
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
|
||||
private fun typeForMarker(marker: String?): SpecialDateType? =
|
||||
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarDataSource"
|
||||
|
||||
@@ -1047,6 +1222,13 @@ 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.
|
||||
*/
|
||||
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||
|
||||
/**
|
||||
* How far ahead/behind a search looks for a recurring event's nearest
|
||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
|
||||
/**
|
||||
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||
*/
|
||||
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||
|
||||
/**
|
||||
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||
* the desired state to decide whether a targeted update is needed.
|
||||
*/
|
||||
data class ManagedEventRow(
|
||||
val eventId: Long,
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
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.domain.contacts.SpecialDateType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Resolves the localized calendar names/templates and per-type colours the
|
||||
* mirror creates its calendars with. The colours are picked from the shared
|
||||
* palette so managed calendars look native alongside user calendars.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SpecialDatesCalendarSpec {
|
||||
|
||||
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun color(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple
|
||||
SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red
|
||||
SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||
import android.util.Log
|
||||
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.parseContactEventDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||
* device's contacts. Read-only and offline — the one-way source for the
|
||||
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||
* the permission is absent so the sync can degrade to a stalled state rather
|
||||
* than crash.
|
||||
*/
|
||||
interface ContactSpecialDatesDataSource {
|
||||
fun hasPermission(): Boolean
|
||||
|
||||
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||
fun readSpecialDates(): List<ContactSpecialDate>
|
||||
}
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||
else -> SpecialDateType.Custom
|
||||
}
|
||||
|
||||
@Singleton
|
||||
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 readSpecialDates(): List<ContactSpecialDate> {
|
||||
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<Pair<String, SpecialDateType>>()
|
||||
val result = ArrayList<ContactSpecialDate>()
|
||||
runCatching {
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
PROJECTION,
|
||||
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||
while (c.moveToNext()) {
|
||||
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(
|
||||
lookupKey = lookup,
|
||||
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||
type = type,
|
||||
month = parts.month,
|
||||
day = parts.day,
|
||||
year = parts.year,
|
||||
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||
return result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ContactSpecialDates"
|
||||
|
||||
val PROJECTION = arrayOf(
|
||||
ContactsContract.Data.LOOKUP_KEY,
|
||||
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||
Event.START_DATE,
|
||||
Event.TYPE,
|
||||
Event.LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<SpecialDatesSyncWorker>(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<SpecialDatesSyncWorker>()
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
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
|
||||
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
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||
|
||||
/**
|
||||
* Localized, per-type presentation the engine needs but can't derive purely
|
||||
* (calendar display name, colour, and the default title template). Kept behind
|
||||
* an interface so the engine's diff stays unit-testable without Android
|
||||
* resources.
|
||||
*/
|
||||
interface SpecialDatesCalendarSpec {
|
||||
fun displayName(type: SpecialDateType): String
|
||||
fun color(type: SpecialDateType): Int
|
||||
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||
*/
|
||||
@Singleton
|
||||
class SpecialDatesSyncEngine @Inject constructor(
|
||||
private val contacts: ContactSpecialDatesDataSource,
|
||||
private val calendars: CalendarDataSource,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val spec: SpecialDatesCalendarSpec,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 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].
|
||||
*/
|
||||
suspend fun sync(today: LocalDate): SpecialDatesSyncResult {
|
||||
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||
|
||||
val enabledTypes = prefs.specialDatesTypes.first()
|
||||
val calendarByType = reconcileCalendars(enabledTypes)
|
||||
|
||||
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||
val reminderCtx = readReminderContext()
|
||||
val templates = prefs.specialDatesTitleTemplates.first()
|
||||
val showAge = prefs.specialDatesShowAge.first()
|
||||
|
||||
calendarByType.forEach { (type, calendarId) ->
|
||||
val template = templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: spec.defaultTitleTemplate(type)
|
||||
syncType(
|
||||
calendarId = calendarId,
|
||||
type = type,
|
||||
contactsOfType = desiredByType[type].orEmpty(),
|
||||
today = today,
|
||||
template = template,
|
||||
showAge = showAge,
|
||||
reminderCtx = reminderCtx,
|
||||
)
|
||||
}
|
||||
return SpecialDatesSyncResult.Success
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||
* existing one, or the stored id if it still exists, else creating one) and
|
||||
* that disabled types have none. Returns the calendar id per enabled type.
|
||||
*/
|
||||
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||
val foundByType = calendars.findManagedCalendars()
|
||||
.groupBy({ it.type }, { it.id })
|
||||
.mapValues { it.value.first() }
|
||||
val stored = prefs.specialDatesCalendars.first()
|
||||
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||
for (type in SpecialDateType.entries) {
|
||||
// A stored id counts only if the calendar still exists (the user may
|
||||
// have deleted it in system settings); otherwise adopt a found one.
|
||||
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||
?: foundByType[type]
|
||||
if (type in enabledTypes) {
|
||||
val id = existingId ?: createCalendar(type)
|
||||
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||
result[type] = id
|
||||
} else {
|
||||
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||
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.
|
||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||
prefs.setCalendarAllDayReminderOverride(
|
||||
id,
|
||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private suspend fun syncType(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
contactsOfType: List<ContactSpecialDate>,
|
||||
today: LocalDate,
|
||||
template: String,
|
||||
showAge: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
) {
|
||||
val built = contactsOfType.map { sd ->
|
||||
buildManagedEvent(calendarId, type, sd, today, template, showAge, reminderCtx)
|
||||
}
|
||||
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||
val formByUid = built.associate { it.desired.uid to it.form }
|
||||
diff.insertUids.forEach { uid ->
|
||||
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||
}
|
||||
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||
}
|
||||
|
||||
private fun buildManagedEvent(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
sd: ContactSpecialDate,
|
||||
today: LocalDate,
|
||||
template: String,
|
||||
showAge: 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)
|
||||
val form = EventForm(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
isAllDay = true,
|
||||
start = start,
|
||||
end = start,
|
||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||
availability = Availability.Free,
|
||||
rrule = YEARLY_RRULE,
|
||||
)
|
||||
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||
return BuiltManagedEvent(
|
||||
desired = ManagedEventDesired(
|
||||
uid = managedEventUid(type, sd.lookupKey),
|
||||
title = title,
|
||||
dtStartMillis = dtStartMillis,
|
||||
rrule = YEARLY_RRULE,
|
||||
),
|
||||
form = form,
|
||||
)
|
||||
}
|
||||
|
||||
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<Int>,
|
||||
val allDayGlobal: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
val allDayTimeMinutes: Int,
|
||||
) {
|
||||
fun resolveAllDay(calendarId: Long): List<Int> = resolveDefaultReminder(
|
||||
timedGlobal = timedGlobal,
|
||||
allDayGlobal = allDayGlobal,
|
||||
timedOverrides = timedOverrides,
|
||||
allDayOverrides = allDayOverrides,
|
||||
calendarId = calendarId,
|
||||
isAllDay = true,
|
||||
)
|
||||
}
|
||||
|
||||
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||
|
||||
private companion object {
|
||||
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||
}
|
||||
}
|
||||
|
||||
/** The managed columns of a desired event, compared against the existing row. */
|
||||
internal data class ManagedEventDesired(
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
|
||||
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||
|
||||
internal data class ManagedDiff(
|
||||
val insertUids: List<String>,
|
||||
val updates: List<ManagedFieldUpdate>,
|
||||
val deleteEventIds: List<Long>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||
* so a re-run with no contact changes produces nothing, and a managed update
|
||||
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||
*/
|
||||
internal fun diffManagedEvents(
|
||||
desired: List<ManagedEventDesired>,
|
||||
existing: List<ManagedEventRow>,
|
||||
): ManagedDiff {
|
||||
val desiredByUid = desired.associateBy { it.uid }
|
||||
val existingByUid = existing.associateBy { it.uid }
|
||||
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||
val updates = buildList {
|
||||
for (d in desired) {
|
||||
val row = existingByUid[d.uid] ?: continue
|
||||
val columns = buildMap<String, Any?> {
|
||||
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||
if (row.dtStartMillis != d.dtStartMillis) {
|
||||
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||
}
|
||||
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||
}
|
||||
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||
}
|
||||
}
|
||||
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindContactSpecialDatesDataSource(
|
||||
impl: AndroidContactSpecialDatesDataSource,
|
||||
): ContactSpecialDatesDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSpecialDatesCalendarSpec(
|
||||
impl: AndroidSpecialDatesCalendarSpec,
|
||||
): SpecialDatesCalendarSpec
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
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.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
@@ -427,6 +428,95 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contact special dates (issue #15) ------------------------------
|
||||
|
||||
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
|
||||
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
|
||||
|
||||
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** The date types the user wants mirrored (default: all three). */
|
||||
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
|
||||
}
|
||||
|
||||
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||
store.edit { it[typeEnabledKey(type)] = enabled }
|
||||
}
|
||||
|
||||
/** The managed calendar id per type, for the ones that currently exist. */
|
||||
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.mapNotNull { type ->
|
||||
prefs[calendarIdKey(type)]?.let { type to it }
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
|
||||
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
|
||||
|
||||
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
|
||||
store.edit { prefs ->
|
||||
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The title template per type ("{name}'s birthday"); an empty string means
|
||||
* unset, so the settings screen can seed the localized default. `{name}` is
|
||||
* the contact name, `{age}` the age at the upcoming date (empty when the
|
||||
* birth year is unknown or [specialDatesShowAge] is off).
|
||||
*/
|
||||
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
|
||||
}
|
||||
|
||||
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||
store.edit { it[titleTemplateKey(type)] = template }
|
||||
}
|
||||
|
||||
/** Whether `{age}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||
val specialDatesShowAge: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_AGE_KEY] ?: true }
|
||||
|
||||
suspend fun setSpecialDatesShowAge(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_SHOW_AGE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Outcome of the last mirror sync, for the settings status line. */
|
||||
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||
SpecialDatesStatus(
|
||||
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||
store.edit { prefs ->
|
||||
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||
}
|
||||
}
|
||||
|
||||
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||
val specialDatesLastForegroundSync: Flow<Long> =
|
||||
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||
|
||||
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||
}
|
||||
|
||||
private fun typeEnabledKey(type: SpecialDateType) =
|
||||
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||
|
||||
private fun calendarIdKey(type: SpecialDateType) =
|
||||
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -479,9 +569,30 @@ class SettingsPrefs @Inject constructor(
|
||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||
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_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 =
|
||||
longPreferencesKey("special_dates_last_foreground_sync")
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||
data class SpecialDatesStatus(
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long,
|
||||
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||
val stalled: SpecialDatesStalledReason?,
|
||||
)
|
||||
|
||||
/** Why the special-dates mirror is paused. */
|
||||
enum class SpecialDatesStalledReason {
|
||||
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||
PermissionRevoked,
|
||||
}
|
||||
|
||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||
data class BackupStatus(
|
||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||
* visibility and reminder defaults for free. See
|
||||
* docs/design/contact-special-dates.md.
|
||||
*/
|
||||
enum class SpecialDateType {
|
||||
Birthday,
|
||||
Anniversary,
|
||||
|
||||
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||
Custom,
|
||||
}
|
||||
|
||||
/**
|
||||
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||
* mirror without duplicating; [year] is null when the contact stored the date
|
||||
* without one (`--MM-dd`), in which case age can't be shown.
|
||||
*/
|
||||
data class ContactSpecialDate(
|
||||
val lookupKey: String,
|
||||
val displayName: String,
|
||||
val type: SpecialDateType,
|
||||
val month: Int,
|
||||
val day: Int,
|
||||
val year: Int?,
|
||||
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/** The parsed month/day (+ optional year) of a contact date. */
|
||||
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||
|
||||
/**
|
||||
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||
*/
|
||||
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||
|
||||
/**
|
||||
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||
* seen in the wild:
|
||||
* - full `yyyy-MM-dd` (year known),
|
||||
* - year-less `--MM-dd` (year null),
|
||||
* - compact `yyyyMMdd`.
|
||||
*
|
||||
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||
* it's unit-tested without a device.
|
||||
*/
|
||||
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||
val s = raw?.trim().orEmpty()
|
||||
if (s.isEmpty()) return null
|
||||
|
||||
// Year-less: "--MM-dd" or "--MMdd".
|
||||
if (s.startsWith("--")) {
|
||||
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||
return validated(null, m, d)
|
||||
}
|
||||
|
||||
if (s.contains('-')) {
|
||||
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||
return when (parts.size) {
|
||||
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||
// first part, so require the first token to look like a year.
|
||||
3 -> if (s.startsWith('-')) null else validated(
|
||||
year = parts[0].toIntOrNull() ?: return null,
|
||||
month = parts[1].toIntOrNull() ?: return null,
|
||||
day = parts[2].toIntOrNull() ?: return null,
|
||||
)
|
||||
// Year-less "MM-dd" without the "--" prefix.
|
||||
2 -> validated(
|
||||
year = null,
|
||||
month = parts[0].toIntOrNull() ?: return null,
|
||||
day = parts[1].toIntOrNull() ?: return null,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Compact "yyyyMMdd".
|
||||
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||
return validated(
|
||||
year = s.substring(0, 4).toInt(),
|
||||
month = s.substring(4, 6).toInt(),
|
||||
day = s.substring(6, 8).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** "MM-dd" or compact "MMdd". */
|
||||
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||
if (rest.contains('-')) {
|
||||
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||
if (p.size != 2) return null
|
||||
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||
}
|
||||
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm month/day form a real calendar date (validated against the actual
|
||||
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||
*/
|
||||
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||
if (month !in 1..12 || day !in 1..31) return null
|
||||
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||
.map { ContactDateParts(year, month, day) }
|
||||
.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
* contact date. Stable across syncs (the reconciliation key), and namespaced by
|
||||
* type so the same contact's birthday and anniversary never collide.
|
||||
*/
|
||||
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
|
||||
"contact-${type.name.lowercase()}:$lookupKey@calendula"
|
||||
|
||||
/**
|
||||
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||
* date when the year is known (so age can be derived), otherwise the month/day
|
||||
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||
* anchor never drifts between syncs.
|
||||
*/
|
||||
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
|
||||
* "Jane's birthday" when no year is known.
|
||||
*/
|
||||
fun renderSpecialDateTitle(template: String, name: String, age: Int?): String =
|
||||
template
|
||||
.replace("{name}", name)
|
||||
.replace("{age}", age?.toString().orEmpty())
|
||||
// Drop an empty "()" left by an unresolved {age}, then tidy spacing.
|
||||
.replace(Regex("""\(\s*\)"""), "")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PickerTarget?>(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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<SpecialDateType?>(null) }
|
||||
var editTemplate by remember { mutableStateOf<SpecialDateType?>(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)
|
||||
|
||||
|
||||
@@ -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> = SpecialDateType.entries.toSet(),
|
||||
val titleTemplates: Map<SpecialDateType, String> = 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,
|
||||
)
|
||||
|
||||
@@ -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<SpecialDatesUiState> = 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) }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||
<string name="permission_rationale_body">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.</string>
|
||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||
@@ -410,4 +410,36 @@
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">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</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||
<string name="special_dates_calendar_custom">Besondere Tage</string>
|
||||
<string name="special_dates_default_title_birthday">{name}s Geburtstag ({age})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({age})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string>
|
||||
<string name="settings_special_dates_subtitle">Geburtstage & Jahrestage aus Kontakten</string>
|
||||
<string name="settings_section_special_dates">Besondere Kontakttage</string>
|
||||
<string name="settings_special_dates_enable">Kontakttage anzeigen</string>
|
||||
<string name="settings_special_dates_enable_hint">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.</string>
|
||||
<string name="settings_special_dates_type_birthday">Geburtstage</string>
|
||||
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
|
||||
<string name="settings_special_dates_type_custom">Weitere Tage</string>
|
||||
<string name="settings_special_dates_template">Titelformat</string>
|
||||
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist).</string>
|
||||
<string name="settings_special_dates_show_age">Alter anzeigen</string>
|
||||
<string name="settings_special_dates_show_age_hint">{age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist</string>
|
||||
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
|
||||
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
|
||||
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Farbe, Sichtbarkeit und Erinnerungen jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
|
||||
<string name="settings_special_dates_paused_title">Pausiert</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
|
||||
<string name="settings_special_dates_grant">Zugriff erlauben</string>
|
||||
<string name="settings_special_dates_disable_title">Kontakttage deaktivieren?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||
<string name="dialog_save">Speichern</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
||||
<string name="permission_rationale_body">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.</string>
|
||||
<string name="permission_request_button">Grant calendar access</string>
|
||||
<string name="permission_denied_title">Calendar access denied</string>
|
||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||
@@ -68,6 +68,8 @@
|
||||
<string name="event_edit_close">Close</string>
|
||||
<string name="event_edit_save">Save</string>
|
||||
<string name="event_edit_title_hint">Add title</string>
|
||||
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
|
||||
<string name="event_edit_starts">Starts</string>
|
||||
<string name="event_edit_ends">Ends</string>
|
||||
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||
@@ -344,6 +346,33 @@
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="settings_section_special_dates">Contact special dates</string>
|
||||
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||
<string name="settings_special_dates_enable_hint">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.</string>
|
||||
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||
<string name="settings_special_dates_template">Title format</string>
|
||||
<string name="settings_special_dates_template_hint">Use {name} for the contact and {age} for their age (hidden when the year is unknown).</string>
|
||||
<string name="settings_special_dates_show_age">Show age</string>
|
||||
<string name="settings_special_dates_show_age_hint">Include {age} in titles when the birth year is known</string>
|
||||
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour, visibility and reminders in Calendars settings.</string>
|
||||
<string name="settings_special_dates_paused_title">Paused</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||
<string name="settings_special_dates_grant">Grant access</string>
|
||||
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||
<string name="dialog_save">Save</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -462,4 +491,13 @@
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||
|
||||
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
|
||||
at the upcoming date (dropped when the year is unknown or Show age is off). -->
|
||||
<string name="special_dates_calendar_birthday">Birthdays</string>
|
||||
<string name="special_dates_calendar_anniversary">Anniversaries</string>
|
||||
<string name="special_dates_calendar_custom">Special dates</string>
|
||||
<string name="special_dates_default_title_birthday">{name}\'s birthday ({age})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({age})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun deleteCalendar(id: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedCalendarIds += id
|
||||
managedCalendarRows.removeAll { it.id == id }
|
||||
managedEvents.remove(id)
|
||||
}
|
||||
|
||||
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||
@@ -139,6 +144,68 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedEventIds += eventId
|
||||
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
|
||||
}
|
||||
|
||||
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
|
||||
|
||||
var nextManagedEventId: Long = 1_000L
|
||||
private var managedCalendarSeq: Long = 700L
|
||||
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
|
||||
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
|
||||
|
||||
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
|
||||
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
|
||||
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
|
||||
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
|
||||
|
||||
/** 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<ManagedCalendarRow> = managedCalendarRows.toList()
|
||||
|
||||
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
|
||||
managedEvents[calendarId]?.toList() ?: emptyList()
|
||||
|
||||
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||
writeError?.let { throw it }
|
||||
insertedManagedEvents += form to uid
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
val id = nextManagedEventId++
|
||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
|
||||
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
|
||||
return id
|
||||
}
|
||||
|
||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||
writeError?.let { throw it }
|
||||
if (values.isEmpty()) return
|
||||
updatedManagedFields += eventId to values
|
||||
managedEvents.values.forEach { rows ->
|
||||
val idx = rows.indexOfFirst { it.eventId == eventId }
|
||||
if (idx >= 0) {
|
||||
var row = rows[idx]
|
||||
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
|
||||
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
|
||||
if (values.containsKey(CalendarContract.Events.RRULE)) {
|
||||
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
|
||||
}
|
||||
rows[idx] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||
|
||||
@@ -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<ContactSpecialDate> = 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<Preferences> = 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<SpecialDatesSyncEngine, SettingsPrefs> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -340,4 +341,59 @@ class SettingsPrefsTest {
|
||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesEnabled.first()).isFalse()
|
||||
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
|
||||
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
|
||||
assertThat(prefs.managedCalendarIds.first()).isEmpty()
|
||||
assertThat(prefs.specialDatesShowAge.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||
assertThat(prefs.specialDatesTypes.first())
|
||||
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
|
||||
assertThat(prefs.specialDatesCalendars.first())
|
||||
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
|
||||
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
|
||||
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
|
||||
assertThat(prefs.specialDatesCalendars.first())
|
||||
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
|
||||
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
|
||||
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
|
||||
.isEqualTo("{name}'s birthday")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesStatus.first())
|
||||
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
|
||||
|
||||
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
|
||||
assertThat(prefs.specialDatesStatus.first())
|
||||
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
|
||||
|
||||
prefs.recordSpecialDatesRun(2000L, null)
|
||||
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ContactSpecialDateTest {
|
||||
|
||||
@Test
|
||||
fun `parses a full yyyy-MM-dd date with year`() {
|
||||
val parts = parseContactEventDate("1990-05-14")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses a year-less --MM-dd date`() {
|
||||
val parts = parseContactEventDate("--05-14")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses a compact yyyyMMdd date`() {
|
||||
val parts = parseContactEventDate("19900514")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses a year-less MM-dd date without the double-dash prefix`() {
|
||||
val parts = parseContactEventDate("12-25")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
|
||||
val parts = parseContactEventDate("--02-29")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps Feb 29 in a real leap year`() {
|
||||
assertThat(parseContactEventDate("2000-02-29"))
|
||||
.isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects Feb 29 in a non-leap year`() {
|
||||
assertThat(parseContactEventDate("1999-02-29")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an impossible month or day`() {
|
||||
assertThat(parseContactEventDate("2020-13-01")).isNull()
|
||||
assertThat(parseContactEventDate("2020-06-31")).isNull()
|
||||
assertThat(parseContactEventDate("--00-10")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects blank, null and garbage input`() {
|
||||
assertThat(parseContactEventDate(null)).isNull()
|
||||
assertThat(parseContactEventDate("")).isNull()
|
||||
assertThat(parseContactEventDate(" ")).isNull()
|
||||
assertThat(parseContactEventDate("not-a-date")).isNull()
|
||||
assertThat(parseContactEventDate("2020")).isNull()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
246
docs/design/contact-special-dates.md
Normal file
246
docs/design/contact-special-dates.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
|
||||
|
||||
Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
|
||||
Date: 2026-06-30 (implemented 2026-07-01)
|
||||
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
|
||||
|
||||
Implementation notes / deviations from this design:
|
||||
|
||||
- **Event identity key is a deterministic `Events.UID_2445`**
|
||||
(`contact-<type>:<lookupKey>@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*
|
||||
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<Int>` (`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<Long, Int?>` 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<Int>` in global defaults + per-calendar
|
||||
override + `resolveDefaultReminder()`. The DataStore string format already
|
||||
parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
|
||||
comma-separated list. Keep the `Inherit / None / Minutes` override states
|
||||
(`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
|
||||
a list.
|
||||
2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
|
||||
UI rather than inventing new interaction.
|
||||
3. New events seed `EventForm.reminders` from the **resolved list** instead of
|
||||
a single value.
|
||||
|
||||
No new permissions, no provider changes, no scheduling changes. Type widening
|
||||
plus one picker upgrade.
|
||||
|
||||
### Why it gates #15
|
||||
|
||||
Each special-dates calendar is a normal local calendar, so it inherits the
|
||||
**per-calendar reminder default** for free. A birthdays calendar is useless
|
||||
without lead time (the reporter literally asked for "a week before *and* on the
|
||||
day") — that requires *multiple* defaults, i.e. #14.
|
||||
|
||||
---
|
||||
|
||||
## #15: Contact special-dates calendars
|
||||
|
||||
### Core model: one-way mirror, one local calendar per type
|
||||
|
||||
Contacts → **separate local calendars per type**:
|
||||
|
||||
- "Birthdays"
|
||||
- "Anniversaries"
|
||||
- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
|
||||
|
||||
Separate-per-type is the key architectural decision: each is a normal local
|
||||
calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
|
||||
**existing per-calendar infra applies for free**:
|
||||
|
||||
- **Color** → existing per-calendar color.
|
||||
- **Show/hide** → existing calendar visibility.
|
||||
- **Default reminders** → per-calendar override from #14.
|
||||
|
||||
This shrinks the new surface dramatically: anniversaries and custom dates are
|
||||
near copy-paste of birthdays (same engine, different `ContactsContract` data
|
||||
kind, one extra local calendar each), and the settings sub-page does **not**
|
||||
need to reinvent color/visibility/reminders.
|
||||
|
||||
### Events: field-level managed, not read-only
|
||||
|
||||
Calendula manages a few fields; the user owns the rest.
|
||||
|
||||
- **Managed** (overwritten on every sync, disabled in the editor for these
|
||||
events): title, start date, recurrence (`FREQ=YEARLY`), existence.
|
||||
- **User-owned** (seeded once on insert, never touched again by sync):
|
||||
location, reminders, notes, busy/free.
|
||||
|
||||
The rule that makes this safe: sync performs a **targeted column update**
|
||||
(managed columns only), **never delete-and-reinsert**. Reminders live in
|
||||
separate rows, so once seeded they are simply never re-touched. A user can move
|
||||
a birthday reminder to "2 weeks before" or add a location and neither is
|
||||
clobbered.
|
||||
|
||||
In the editor, when an event belongs to a managed calendar: disable the
|
||||
title/date/recurrence fields; allow the rest.
|
||||
|
||||
### Reconciliation identity
|
||||
|
||||
Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
|
||||
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
|
||||
|
||||
- contact added → insert
|
||||
- birthday/date changed → targeted update of managed columns
|
||||
- contact or date removed → delete the event
|
||||
|
||||
Getting this wrong = duplicate birthdays on every sync, so the identity key is
|
||||
non-negotiable.
|
||||
|
||||
### Data source
|
||||
|
||||
`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
|
||||
`Event.TYPE`:
|
||||
|
||||
- `TYPE_BIRTHDAY` → Birthdays calendar
|
||||
- `TYPE_ANNIVERSARY` → Anniversaries calendar
|
||||
- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
|
||||
|
||||
### Dates: year-less + age
|
||||
|
||||
Many contacts store `--MM-DD` (no year). Handle both:
|
||||
|
||||
- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
|
||||
age ("Jane turns 30") when the show-age option is on.
|
||||
- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
|
||||
|
||||
Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
|
||||
(`Recurrence.kt:32-174`).
|
||||
|
||||
### Auto-update cadence
|
||||
|
||||
Birthdays change rarely, so nothing live/expensive:
|
||||
|
||||
- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
|
||||
(catches new/edited/deleted contacts even if the app isn't opened).
|
||||
- **No `ContentObserver`** — needs the process alive and buys almost nothing
|
||||
for once-a-year events.
|
||||
|
||||
### Permission + lifecycle
|
||||
|
||||
- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
|
||||
contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
|
||||
grant, create the enabled calendars and run the first sync.
|
||||
- **Disable:** delete the managed calendars + their events (with a confirm).
|
||||
- **Permission revoked in system settings:** detect on next sync; surface that
|
||||
the feature is stalled rather than failing silently.
|
||||
|
||||
### Configurable settings sub-page
|
||||
|
||||
Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
|
||||
color/visibility/reminders already live in normal calendar settings, the
|
||||
sub-page only carries the genuinely new knobs:
|
||||
|
||||
- Master enable toggle.
|
||||
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
|
||||
- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
|
||||
- Show-age toggle (only meaningful when the year is known).
|
||||
- (Possibly) sync-cadence / "sync now".
|
||||
- Pointer to the normal calendar settings for color, visibility, reminders.
|
||||
|
||||
The owner wants this broadly configurable — keep the sub-page the home for
|
||||
display/behaviour options, and lean on existing per-calendar settings for the
|
||||
rest.
|
||||
|
||||
---
|
||||
|
||||
## Open questions / deferred
|
||||
|
||||
- Title template: free-form vs a small set of presets. Start with one good
|
||||
localized default; revisit configurability.
|
||||
- Whether "Custom dates" is one bucket or split further by label. Start: one
|
||||
bucket.
|
||||
- Contact scope: all contacts vs per-account/group filtering. Start: all;
|
||||
filtering is a later refinement.
|
||||
|
||||
## Task outline
|
||||
|
||||
### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
|
||||
- [x] Widen global + per-calendar reminder defaults `Int` → `List<Int>`
|
||||
(`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
|
||||
- [x] `resolveDefaultReminder()` returns a list.
|
||||
- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
|
||||
- [x] Seed new events' `EventForm.reminders` from the resolved list.
|
||||
- [x] Migration / parse compatibility for existing single-value stored prefs.
|
||||
- [x] Tests: resolution, parse round-trip, all-day variant.
|
||||
|
||||
### #15 — contact special-dates (`feat/contact-special-dates`)
|
||||
- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
|
||||
- [x] Per-type local calendars (create on enable, delete on disable+confirm).
|
||||
- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
|
||||
- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
|
||||
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
|
||||
user-owned fields on insert only.
|
||||
- [x] `FREQ=YEARLY` event generation + 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user