Compare commits

...

5 Commits

Author SHA1 Message Date
86d4e11584 refactor(contacts): simplify the sync engine's gates and context
- The worker no longer pre-checks specialDatesEnabled: the engine already
  gates on it, and the worker now consumes the previously-unreachable
  Disabled result instead (without recording a run).
- ReminderContext drops the timed defaults it could never use — managed
  events are always all-day — saving two DataStore reads per sync.
- createCalendar no longer persists the calendar id itself;
  reconcileCalendars is the single place ids are recorded (it always
  followed up with the same write).
- The title-template fallback uses the shared resolveTitleTemplate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:44 +02:00
67a07a9346 refactor(settings): dedupe override decoding + on-convention template input
- choiceFor moves next to CalendarReminderOverride in SettingsPrefs and
  replaces the hand-rolled null/empty/else decoder in SettingsViewModel,
  so the picker and the special-dates rows can't drift apart.
- The special-dates template dialog used the codebase's only
  OutlinedTextField; it now follows the documented dialog convention
  (InlineTextField over a tonal surface, see DialogControls).
- reminderChoiceLabel drops the manual loop (and its wrong comment —
  map is inline, so composable calls are fine in its lambda).
- The blank-template fallback is shared with the sync engine via
  resolveTitleTemplate instead of being copy-pasted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:44 +02:00
b8a420bdab refactor(contacts): one Context.hasContactsPermission() helper
The READ_CONTACTS check was written out three times (data source, RootScreen
resume trigger, settings screen). A single extension in the contacts package
mirrors the existing Context.hasCalendarPermission() precedent; the settings
call site follows in the settings cleanup commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:44 +02:00
442697d57c refactor(calendar): move the colour palette to data with named entries
AndroidSpecialDatesCalendarSpec hardcoded three ARGB literals that its KDoc
claimed were 'picked from the shared palette' — a silent-drift hazard, and
referencing ui.common from data would invert layering. The palette now lives
in data/calendar as CalendarColorPalette with named hues; the swatch pickers
use CalendarColorPalette.all and the spec references Purple/Red/Blue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:26 +02:00
8e3a17fdc3 refactor(calendar): share the insert values builder + reminder seeding
insertManagedEvent duplicated ~45 lines of insertEvent (ContentValues build
and the reminder-seeding loop). Both now go through buildEventInsertValues
in EventWriteMapper and a shared seedReminders, differing only in the UID
they stamp and the colour/attendee columns insertEvent layers on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:26 +02:00
14 changed files with 173 additions and 163 deletions

View File

@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.data.calendar
/**
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
* `EVENT_COLOR`. The named entries exist for callers that need one specific
* hue (the managed special-dates calendars), so they can't drift from the
* swatches offered in the colour picker.
*/
object CalendarColorPalette {
val Red = 0xFFD50000.toInt()
val Orange = 0xFFE67C00.toInt()
val Amber = 0xFFF6BF26.toInt()
val Green = 0xFF33B679.toInt()
val DarkGreen = 0xFF0B8043.toInt()
val Blue = 0xFF039BE5.toInt()
val Indigo = 0xFF3F51B5.toInt()
val Purple = 0xFF8E24AA.toInt()
val Graphite = 0xFF616161.toInt()
/** The full palette, in swatch-row order. */
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
}

View File

@@ -416,47 +416,15 @@ class AndroidCalendarDataSource @Inject constructor(
} ?: emptyList() } ?: emptyList()
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long { 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 // Deterministic UID = the reconciliation key. A re-sync reads it back
// (queryManagedEvents) and diffs on it, so a contact never doubles. // (queryManagedEvents) and diffs on it, so a contact never doubles.
put(CalendarContract.Events.UID_2445, uid) val values = buildEventInsertValues(form, uid, form.toWriteTimes(ZoneId.systemDefault()))
put(CalendarContract.Events.TITLE, form.title.trim()) val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
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) ?.let(ContentUris::parseId)
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}") ?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
// Seeded once, then never re-touched by sync — so a user who moves a // Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. // birthday reminder keeps their change.
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes -> seedReminders(eventId, form, allDayReminderTimeMinutes)
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 return eventId
} }
@@ -779,35 +747,15 @@ class AndroidCalendarDataSource @Inject constructor(
.distinct() .distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long { override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
// A globally-unique UID so a later .ics backup/restore can identify // A globally-unique UID so a later .ics backup/restore can identify
// the event and not duplicate it on re-import (the provider leaves // the event and not duplicate it on re-import (the provider leaves
// this null for events it didn't sync). Older rows without one fall // this null for events it didn't sync). Older rows without one fall
// back to a stable synthesised UID at export time (deriveIcsUid). // back to a stable synthesised UID at export time (deriveIcsUid).
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula") val values = buildEventInsertValues(
put(CalendarContract.Events.TITLE, form.title.trim()) form,
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) uid = "${UUID.randomUUID()}@calendula",
put(CalendarContract.Events.DTSTART, times.dtStartMillis) times = form.toWriteTimes(ZoneId.systemDefault()),
// The provider's invariant: recurring rows carry RRULE+DURATION ).toContentValues().apply {
// (and no DTEND), one-off rows carry DTEND.
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
// A null colour just leaves both columns unset (the event inherits // A null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written. // its calendar's colour), so only the key/raw cases are written.
when { when {
@@ -819,10 +767,21 @@ class AndroidCalendarDataSource @Inject constructor(
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values) val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri) val eventId = ContentUris.parseId(uri)
// Best effort (spec §8): the event exists at this point — a reminder seedReminders(eventId, form, allDayReminderTimeMinutes)
// that fails to attach is logged, not surfaced as a failed create. // Guests are best-effort like reminders: a row that fails to attach is
encodedReminders(form, allDayReminderTimeMinutes) // logged, not surfaced as a failed create. Calendula never sends an
.forEach { minutes -> // invitation — it only writes the rows; the backend decides delivery.
insertAttendees(eventId, form.attendees)
return eventId
}
/**
* Attach [form]'s reminders to a freshly inserted event. Best effort
* (spec §8): the event exists at this point — a reminder that fails to
* attach is logged, not surfaced as a failed create.
*/
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) {
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes ->
val reminder = ContentValues().apply { val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId) put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes) put(CalendarContract.Reminders.MINUTES, minutes)
@@ -832,11 +791,6 @@ class AndroidCalendarDataSource @Inject constructor(
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
} }
} }
// Guests are best-effort like reminders: a row that fails to attach is
// logged, not surfaced as a failed create. Calendula never sends an
// invitation — it only writes the rows; the backend decides delivery.
insertAttendees(eventId, form.attendees)
return eventId
} }
override fun updateEvent( override fun updateEvent(

View File

@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
"P${(dtEndMillis - dtStartMillis) / 1_000L}S" "P${(dtEndMillis - dtStartMillis) / 1_000L}S"
} }
/**
* Column values for a brand-new Events row: identity, times (the provider's
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
* carry DTEND), availability/access level and trimmed optional text. Shared by
* the user-event and managed-event insert paths, which differ only in the
* [uid] they stamp; colour and attendees are user-event concerns the caller
* layers on top.
*/
internal fun buildEventInsertValues(
form: EventForm,
uid: String,
times: EventWriteTimes,
): Map<String, Any?> = buildMap {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
/** /**
* Dirty-checked column values for updating an existing Events row: only what * Dirty-checked column values for updating an existing Events row: only what
* the user actually changed is written, so untouched fields can't stomp * the user actually changed is written, so untouched fields can't stomp

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.contacts
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -34,8 +35,8 @@ class AndroidSpecialDatesCalendarSpec @Inject constructor(
) )
override fun color(type: SpecialDateType): Int = when (type) { override fun color(type: SpecialDateType): Int = when (type) {
SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple SpecialDateType.Birthday -> CalendarColorPalette.Purple
SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red SpecialDateType.Anniversary -> CalendarColorPalette.Red
SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue SpecialDateType.Custom -> CalendarColorPalette.Blue
} }
} }

View File

@@ -28,6 +28,11 @@ interface ContactSpecialDatesDataSource {
fun readSpecialDates(): List<ContactSpecialDate> fun readSpecialDates(): List<ContactSpecialDate>
} }
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */ /** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) { internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
@@ -40,9 +45,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ContactSpecialDatesDataSource { ) : ContactSpecialDatesDataSource {
override fun hasPermission(): Boolean = override fun hasPermission(): Boolean = context.hasContactsPermission()
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
override fun readSpecialDates(): List<ContactSpecialDate> { override fun readSpecialDates(): List<ContactSpecialDate> {
if (!hasPermission()) return emptyList() if (!hasPermission()) return emptyList()

View File

@@ -83,9 +83,6 @@ class SpecialDatesSyncWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
val prefs = deps.settingsPrefs() 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() val now = System.currentTimeMillis()
// A foreground resume can fire often — skip if we synced recently. // A foreground resume can fire often — skip if we synced recently.
@@ -95,13 +92,19 @@ class SpecialDatesSyncWorker(
} }
return try { return try {
val result = deps.syncEngine().sync() when (val result = deps.syncEngine().sync()) {
// The engine re-checks the toggle, so already-queued work never
// revives a disabled feature; don't record a run either.
SpecialDatesSyncResult.Disabled -> return Result.success()
else -> {
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) { val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
SpecialDatesStalledReason.PermissionRevoked SpecialDatesStalledReason.PermissionRevoked
} else { } else {
null null
} }
prefs.recordSpecialDatesRun(now, stalled) prefs.recordSpecialDatesRun(now, stalled)
}
}
if (foreground) prefs.setSpecialDatesLastForegroundSync(now) if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -6,7 +6,6 @@ import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.Availability
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
@@ -39,6 +38,16 @@ interface SpecialDatesCalendarSpec {
fun defaultTitleTemplate(type: SpecialDateType): String fun defaultTitleTemplate(type: SpecialDateType): String
} }
/**
* The stored title template for [type], falling back to the localized default
* when blank/absent — the one resolution rule shared by the sync (what gets
* written) and the settings editor (what gets shown).
*/
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
type: SpecialDateType,
stored: Map<SpecialDateType, String>,
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
/** /**
* Mirrors contact birthdays/anniversaries/custom dates into local calendars, * Mirrors contact birthdays/anniversaries/custom dates into local calendars,
* one per type. Every reconcile is an idempotent diff keyed on the deterministic * one per type. Every reconcile is an idempotent diff keyed on the deterministic
@@ -72,8 +81,7 @@ class SpecialDatesSyncEngine @Inject constructor(
val showYear = prefs.specialDatesShowYear.first() val showYear = prefs.specialDatesShowYear.first()
calendarByType.forEach { (type, calendarId) -> calendarByType.forEach { (type, calendarId) ->
val template = templates[type]?.takeIf { it.isNotBlank() } val template = spec.resolveTitleTemplate(type, templates)
?: spec.defaultTitleTemplate(type)
syncType( syncType(
calendarId = calendarId, calendarId = calendarId,
type = type, type = type,
@@ -143,8 +151,8 @@ class SpecialDatesSyncEngine @Inject constructor(
} }
private suspend fun createCalendar(type: SpecialDateType): Long { private suspend fun createCalendar(type: SpecialDateType): Long {
// reconcileCalendars persists the id — the single place ids are recorded.
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type) 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 // 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 — // birthdays get lead time out of the box. Per-calendar, user-adjustable —
// only set when the user hasn't already configured this calendar. // only set when the user hasn't already configured this calendar.
@@ -212,29 +220,21 @@ class SpecialDatesSyncEngine @Inject constructor(
) )
} }
// Managed events are always all-day, so only the all-day defaults apply.
private suspend fun readReminderContext(): ReminderContext = ReminderContext( private suspend fun readReminderContext(): ReminderContext = ReminderContext(
timedGlobal = prefs.defaultReminderMinutes.first(),
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(), allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
timedOverrides = prefs.perCalendarReminderOverride.first(),
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(), allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(), allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
) )
private data class ReminderContext( private data class ReminderContext(
val timedGlobal: List<Int>,
val allDayGlobal: List<Int>, val allDayGlobal: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>, val allDayOverrides: Map<Long, List<Int>>,
val allDayTimeMinutes: Int, val allDayTimeMinutes: Int,
) { ) {
fun resolveAllDay(calendarId: Long): List<Int> = resolveDefaultReminder( /** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
timedGlobal = timedGlobal, fun resolveAllDay(calendarId: Long): List<Int> =
allDayGlobal = allDayGlobal, allDayOverrides[calendarId] ?: allDayGlobal
timedOverrides = timedOverrides,
allDayOverrides = allDayOverrides,
calendarId = calendarId,
isAllDay = true,
)
} }
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm) private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)

View File

@@ -611,6 +611,18 @@ sealed interface CalendarReminderOverride {
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
} }
/**
* The stored override for [calendarId] in an override map, as a
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
* empty → [CalendarReminderOverride.None]) — the inverse of how
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
*/
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
}
/** /**
* The lead times to prefill on a new event: the matching per-calendar override * The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for * if [calendarId] has one for this event kind, otherwise the global default for

View File

@@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@@ -53,10 +54,7 @@ fun RootScreen(
// Refresh the contact special-dates mirror on foreground (the // Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated // worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it. // on the permission so users without the opt-in never enqueue it.
if (ContextCompat.checkSelfPermission( if (context.hasContactsPermission()) {
context, Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED
) {
SpecialDatesScheduler.runNow(context, foreground = true) SpecialDatesScheduler.runNow(context, foreground = true)
} }
} }

View File

@@ -94,7 +94,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
@@ -142,7 +142,7 @@ fun CalendarsScreen(
sessionKey = editorSession, sessionKey = editorSession,
isNew = editorId == NEW_CALENDAR_ID, isNew = editorId == NEW_CALENDAR_ID,
initialName = editing?.displayName.orEmpty(), initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(), initialColor = editing?.color ?: CalendarColorPalette.all.first(),
initialDescription = editing?.description.orEmpty(), initialDescription = editing?.description.orEmpty(),
onSave = { name, color, description -> onSave = { name, color, description ->
val id = editorId val id = editorId
@@ -507,7 +507,7 @@ private fun CalendarEditor(
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
ColorSwatchRow( ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE, colors = CalendarColorPalette.all,
selected = color, selected = color,
onSelect = { color = it }, onSelect = { color = it },
dark = dark, dark = dark,

View File

@@ -67,16 +67,3 @@ fun ColorSwatchRow(
} }
} }
} }
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
0xFFD50000, // red
0xFFE67C00, // orange
0xFFF6BF26, // amber
0xFF33B679, // green
0xFF0B8043, // dark green
0xFF039BE5, // blue
0xFF3F51B5, // indigo
0xFF8E24AA, // purple
0xFF616161, // graphite
).map { it.toInt() }

View File

@@ -119,7 +119,7 @@ import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
@@ -1745,7 +1745,7 @@ private fun ColorPickerDialog(
) )
} else { } else {
ColorSwatchRow( ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE, colors = CalendarColorPalette.all,
selected = selected, selected = selected,
onSelect = onPickRaw, onSelect = onPickRaw,
dark = dark, dark = dark,

View File

@@ -86,8 +86,10 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
@@ -104,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
@@ -1090,7 +1093,7 @@ private fun SpecialDatesScreen(
} }
} }
val requestOrEnable: () -> Unit = { val requestOrEnable: () -> Unit = {
if (hasContactsPermission(context)) { if (context.hasContactsPermission()) {
viewModel.setSpecialDatesEnabled(true) viewModel.setSpecialDatesEnabled(true)
} else { } else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS) permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
@@ -1290,12 +1293,21 @@ private fun SpecialDatesTemplateDialog(
title = { Text(stringResource(R.string.settings_special_dates_template)) }, title = { Text(stringResource(R.string.settings_special_dates_template)) },
text = { text = {
Column { Column {
androidx.compose.material3.OutlinedTextField( // The app's borderless input over a tonal surface (the dialog
// convention — see DialogControls), not Material's outlined field.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
singleLine = true, placeholder = stringResource(R.string.settings_special_dates_template),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
) )
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.settings_special_dates_template_hint), text = stringResource(R.string.settings_special_dates_template_hint),
@@ -1324,10 +1336,6 @@ private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom 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 @Composable
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String = private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
if (lastRun <= 0L) { if (lastRun <= 0L) {
@@ -1426,22 +1434,11 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
return DateFormat.getTimeFormat(context).format(time) return DateFormat.getTimeFormat(context).format(time)
} }
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
}
/** Label for a global-default choice: empty → "None", else the lead times joined. */ /** Label for a global-default choice: empty → "None", else the lead times joined. */
@Composable @Composable
private fun reminderChoiceLabel(minutes: List<Int>): String { private fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes.isEmpty()) return stringResource(R.string.reminder_none) if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
// than a (non-composable) joinToString transform.
val parts = ArrayList<String>(minutes.size)
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
return parts.joinToString(", ")
} }
/** Row summary for a calendar: its override, or the inherited global default. */ /** Row summary for a calendar: its override, or the inherited global default. */

View File

@@ -13,8 +13,10 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
@@ -195,8 +197,7 @@ class SettingsViewModel @Inject constructor(
// A blank stored template resolves to the localized default so the // A blank stored template resolves to the localized default so the
// editor always shows something sensible. // editor always shows something sensible.
titleTemplates = SpecialDateType.entries.associateWith { type -> titleTemplates = SpecialDateType.entries.associateWith { type ->
templates[type]?.takeIf { it.isNotBlank() } specialDatesSpec.resolveTitleTemplate(type, templates)
?: specialDatesSpec.defaultTitleTemplate(type)
}, },
showYear = showYear, showYear = showYear,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked, stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
@@ -207,13 +208,9 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarAllDayReminderOverride, prefs.perCalendarAllDayReminderOverride,
) { base, calendars, allDayOverrides -> ) { base, calendars, allDayOverrides ->
base.copy( base.copy(
reminderChoices = calendars.mapValues { (_, calendarId) ->
// An override is always seeded on creation; present-empty = None. // An override is always seeded on creation; present-empty = None.
when (val minutes = allDayOverrides[calendarId]) { reminderChoices = calendars.mapValues { (_, calendarId) ->
null -> CalendarReminderOverride.Inherit allDayOverrides.choiceFor(calendarId)
emptyList<Int>() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(minutes)
}
}, },
) )
}.stateIn( }.stateIn(