Compare commits

..

3 Commits

Author SHA1 Message Date
2a035271c4 fix(edit): curate the CalDAV colour picker in painted space (#22)
The picker paints every swatch through pastelize, which pins lightness to
a constant and caps saturation — so the raw palette's lightness axis is
invisible on screen. Curating in raw space (as the first pass did) left
near-identical painted swatches (navy/blue/midnightblue all paint as one
mid blue) and stranded every neutral as an identical pale tint in a run of
"pinks" at the end of the grid.

Move dedup, thinning, and ordering into the painted (pastelised) space the
user actually sees: colours that paint identically collapse to one,
oversized palettes drop washed-out neutral-origin tints (painted chroma
below a floor) and thin by CIE76 ΔE in painted Lab, and the survivors sort
continuously by painted hue with the wheel cut at its widest gap. The CSS3
147-colour dump now lands at ~33 distinct, rainbow-ordered swatches with no
neutrals stranded at the end.

The hue/saturation shaping moves into a shared domain pastelArgb() so the
picker paints exactly what curation reasons about; ui/pastelize wraps it and
only picks the theme's brightness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:29:46 +02:00
1fe887fbc4 docs(changelog): note curated CalDAV colour picker (#22)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:37:34 +02:00
bf4f0a208d fix(edit): curate oversized CalDAV event-colour palettes (#22)
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the picker showed a full screen of
alphabetically-scrambled, partly duplicated swatches. Palettes are now
deduped by value, thinned to perceptually distinct colours (CIE76
deltaE >= 25 in Lab) when oversized, and sorted around the hue wheel
with achromatics last. Small palettes (Google's curated set) pass
through untouched, and every surviving swatch keeps its provider
colour key so picks still round-trip through sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:36:50 +02:00
71 changed files with 643 additions and 4377 deletions

View File

@@ -8,17 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- See your contacts' birthdays and anniversaries in your calendar. A new,
**optional** feature (Settings → Contact special dates) mirrors your contacts'
birthdays, anniversaries and other dates into local "Birthdays",
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
change. Each is a normal local calendar, so you set its colour, visibility and
reminders the usual way — new birthdays even start with a reminder a week before
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
This is the first feature to use the contacts permission: it is requested only
when you turn the feature on, everything stays on your device (Calendula has no
internet access), and your contacts are only ever read, never changed. Thanks
to @moonj for the suggestion ([#15]).
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
@@ -35,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
suggestions ([#18], [#20]).
### Fixed
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
app (such as DAVx5), the event colour picker showed every colour the account
publishes — nearly 150 swatches in alphabetical order, many of them
duplicates or near-identical shades. The picker now shows only visually
distinct colours, arranged as a rainbow; near-duplicate shades and the
washed-out neutrals are folded away so no two swatches look alike. Picked
colours still sync exactly as before, and calendars with hand-picked
palettes (like Google's) are unaffected. Thanks to @ptab for the report
([#22]).
- Month widget arrows and "today" button work again. On release builds the
prev/next-month arrows and the jump-to-today control on the month widget did
nothing when tapped — code shrinking had stripped the tap handlers behind
@@ -775,8 +773,8 @@ automatically, with zero telemetry and no internet permission.
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22

View File

@@ -5,15 +5,6 @@
<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

View File

@@ -5,8 +5,6 @@ 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
@@ -27,7 +25,6 @@ class CalendulaApp : Application() {
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
reconcileAutoBackup()
reconcileSpecialDates()
}
/**
@@ -47,21 +44,4 @@ 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(),
)
}
}
}

View File

@@ -34,11 +34,8 @@ import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -102,22 +99,9 @@ class MainActivity : AppCompatActivity() {
val use24Hour = remember(settings.timeFormat, context) {
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
}
// The user's custom-font choice, resolved to a Material typography
// (issue #19). Recomputed only when a token — or the custom-font
// re-import stamp AppFontSettings carries, so replacing the file
// behind an active "custom" token still refreshes — changes;
// "system for both" returns the default scale untouched.
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
val typography = remember(fonts, context) {
calendulaTypography(
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
)
}
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
typography = typography,
) {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(

View File

@@ -50,27 +50,6 @@ internal fun toProviderAllDayMinutes(
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
}
/**
* The next date on or after [today] carrying [month]/[day] — the date to sample
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
* ancient anchor (1972 for year-less dates), a year whose timezone rules
* (pre-DST offsets) differ from today's; sampling the offset there skews every
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
* the inherent limit). Feb-29 skips forward to the next leap year.
*/
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
var year = today.year
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
repeat(8) {
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
if (candidate != null && !candidate.isBefore(today)) return candidate
year++
}
// Unreachable for real month/day inputs; fall back to the raw anchor.
return LocalDate.of(today.year, month, day)
}
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it

View File

@@ -1,22 +0,0 @@
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

@@ -20,18 +20,15 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
@@ -61,7 +58,10 @@ interface CalendarDataSource {
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see
* [curatedForPicker]. Empty when the
* account exposes no palette (most local calendars, some CalDAV) — the
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
* which a synced calendar may drop on its next sync.
@@ -101,55 +101,6 @@ 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?>)
/**
* Replace the reminders on **every managed** event in [calendarId] with
* [minutes] (all-day lead times), each encoded so it fires at
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
* special-dates section so a reminder change applies to existing events too,
* not just future ones — the managed calendars treat reminders as a
* calendar-level setting. Skips any non-managed (user) event in the calendar.
*/
fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
)
/**
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
* (minutes from local midnight) is the wall-clock time all-day reminders
@@ -345,140 +296,6 @@ 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,
),
// Only our own mirror events (UID prefix), so a user event that somehow
// landed in this calendar (e.g. an .ics import) is never seen as
// "existing but not desired" and deleted by the diff.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
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 {
// Deterministic UID = the reconciliation key. A re-sync reads it back
// (queryManagedEvents) and diffs on it, so a contact never doubles.
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = buildEventInsertValues(form, uid, times)
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
?.let(ContentUris::parseId)
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
// Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. Encoded against the upcoming
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows(
eventId,
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
)
return eventId
}
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 applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
// Only our own mirror events, so a stray user event in this calendar
// isn't all-day-re-encoded or stripped of its own reminders.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
reconcileReminders(
eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
)
}
}
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
@@ -607,7 +424,7 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?.sortedBy { it.key }
?.curatedForPicker()
?: emptyList()
}
@@ -762,15 +579,35 @@ class AndroidCalendarDataSource @Inject constructor(
.distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
// A globally-unique UID so a later .ics backup/restore can identify
// the event and not duplicate it on re-import (the provider leaves
// this null for events it didn't sync). Older rows without one fall
// back to a stable synthesised UID at export time (deriveIcsUid).
val values = buildEventInsertValues(
form,
uid = "${UUID.randomUUID()}@calendula",
times = form.toWriteTimes(ZoneId.systemDefault()),
).toContentValues().apply {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
// A globally-unique UID so a later .ics backup/restore can identify
// the event and not duplicate it on re-import (the provider leaves
// this null for events it didn't sync). Older rows without one fall
// back to a stable synthesised UID at export time (deriveIcsUid).
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
// The provider's invariant: recurring rows carry RRULE+DURATION
// (and no DTEND), one-off rows carry DTEND.
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
// A null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written.
when {
@@ -782,7 +619,19 @@ class AndroidCalendarDataSource @Inject constructor(
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri)
seedReminders(eventId, form, allDayReminderTimeMinutes)
// Best effort (spec §8): the event exists at this point — a reminder
// that fails to attach is logged, not surfaced as a failed create.
encodedReminders(form, allDayReminderTimeMinutes)
.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
// 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.
@@ -790,47 +639,6 @@ class AndroidCalendarDataSource @Inject constructor(
return eventId
}
/**
* Attach [form]'s reminders to a freshly inserted event. Best effort
* (spec §8): the event exists at this point — a reminder that fails to
* attach is logged, not surfaced as a failed create.
*/
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
}
/**
* A managed all-day event's reminders, encoded so they fire at
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
* it directly would sample a stale timezone offset, so the offset is sampled
* at the next occurrence of the anchor's month/day instead.
*/
private fun managedAllDayReminderMinutes(
anchor: LocalDate,
reminders: List<Int>,
allDayReminderTimeMinutes: Int,
): List<Int> {
val zone = ZoneId.systemDefault()
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
return reminders
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
.distinct()
}
override fun updateEvent(
eventId: Long,
original: EventForm,
@@ -1234,17 +1042,6 @@ 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"
@@ -1254,14 +1051,6 @@ class AndroidCalendarDataSource @Inject constructor(
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
/**
* Column and namespace for the special-dates marker (shared with the
* calendar projection, which reads it into [CalendarSource.isManaged]).
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
*/
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
/**
* How far ahead/behind a search looks for a recurring event's nearest
* occurrence (~2 years). Wide enough for everyday series; a series that

View File

@@ -24,12 +24,5 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
} else {
null
},
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
// marker (only meaningful on the local calendars the app owns). This is
// the source of truth for the editor lock — independent of any stored
// preference that a backup restore could have wiped.
isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
)
}

View File

@@ -49,42 +49,6 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
}
/**
* Column values for a brand-new Events row: identity, times (the provider's
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
* carry DTEND), availability/access level and trimmed optional text. Shared by
* the user-event and managed-event insert paths, which differ only in the
* [uid] they stamp; colour and attendees are user-event concerns the caller
* layers on top.
*/
internal fun buildEventInsertValues(
form: EventForm,
uid: String,
times: EventWriteTimes,
): Map<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
* the user actually changed is written, so untouched fields can't stomp

View File

@@ -1,24 +0,0 @@
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?,
)

View File

@@ -15,17 +15,9 @@ internal object CalendarProjection {
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
// so the mapper only reads it for local calendars).
DESCRIPTION_COLUMN,
// The special-dates marker (CAL_SYNC2) — the durable identity the app
// uses to recognise its own managed calendars, independent of any
// stored preference id (which a backup restore / data wipe can lose).
MANAGED_MARKER_COLUMN,
)
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
const val IDX_ID = 0
const val IDX_DISPLAY_NAME = 1
@@ -35,7 +27,6 @@ internal object CalendarProjection {
const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7
const val IDX_MANAGED_MARKER = 8
}
internal object InstanceProjection {

View File

@@ -1,42 +0,0 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import javax.inject.Inject
import javax.inject.Singleton
/**
* 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 -> CalendarColorPalette.Purple
SpecialDateType.Anniversary -> CalendarColorPalette.Red
SpecialDateType.Custom -> CalendarColorPalette.Blue
}
}

View File

@@ -1,105 +0,0 @@
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.managedUid
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>
}
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
else -> SpecialDateType.Custom
}
@Singleton
class AndroidContactSpecialDatesDataSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ContactSpecialDatesDataSource {
override fun hasPermission(): Boolean = context.hasContactsPermission()
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); dedup on the mirror's reconciliation key so exact
// duplicates collapse while genuinely distinct dates (two custom events on
// one contact) are all kept. Ordered by Data._ID so which of two truly
// conflicting rows wins is stable across syncs (no event ping-pong).
val seen = HashSet<String>()
val result = ArrayList<ContactSpecialDate>()
runCatching {
resolver.query(
ContactsContract.Data.CONTENT_URI,
PROJECTION,
"${ContactsContract.Data.MIMETYPE} = ?",
arrayOf(Event.CONTENT_ITEM_TYPE),
"${ContactsContract.Data._ID} ASC",
)?.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))
val date = 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() },
)
if (seen.add(date.managedUid())) result += date
}
}
}.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,
)
}
}

View File

@@ -1,135 +0,0 @@
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 java.util.concurrent.TimeUnit
/**
* 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 WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
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)
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
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 (the app resuming) are
* debounced inside the worker and use their own work name, so a frequent
* foreground resync can never REPLACE — and swallow — a pending enable /
* "Sync now" run, which always syncs. The engine serializes the two if they
* overlap.
*/
fun runNow(context: Context, foreground: Boolean = false) {
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
.build()
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
WorkManager.getInstance(context)
.enqueueUniqueWork(name, 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()
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()
}
return try {
when (val result = deps.syncEngine().sync()) {
// The engine re-checks the toggle, so already-queued work never
// revives a disabled feature; don't record a run either.
SpecialDatesSyncResult.Disabled -> return Result.success()
else -> {
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
SpecialDatesStalledReason.PermissionRevoked
} else {
null
}
prefs.recordSpecialDatesRun(now, stalled)
}
}
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
Result.success()
} catch (e: SecurityException) {
// A revoked calendar/contacts permission won't fix itself on retry —
// park the feature in a stalled state (surfaced in settings) instead
// of retrying with backoff forever.
Log.w(TAG, "Special-dates sync lacks a required permission", e)
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
Result.success()
} catch (e: Exception) {
Log.w(TAG, "Special-dates sync failed", e)
Result.retry()
}
}
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
}
}

View File

@@ -1,304 +0,0 @@
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.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.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
import de.jeanlucmakiola.calendula.domain.toRRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import java.time.ZoneId
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
}
/**
* 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,
* 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,
) {
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
// racing a "Sync now"/foreground run) would each see no managed calendar and
// both create one; a teardown racing an in-flight sync would delete calendars
// the sync then recreates. Holding this for the whole of sync()/teardown()
// makes those check-then-act sequences atomic, and — because sync() re-reads
// the enabled flag inside the lock — a teardown always wins the race.
private val lifecycleMutex = Mutex()
/**
* Reconcile every enabled type against the device's contacts. Returns why it
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
*/
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
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 showYear = prefs.specialDatesShowYear.first()
calendarByType.forEach { (type, calendarId) ->
val template = spec.resolveTitleTemplate(type, templates)
syncType(
calendarId = calendarId,
type = type,
contactsOfType = desiredByType[type].orEmpty(),
template = template,
showYear = showYear,
reminderCtx = reminderCtx,
)
}
SpecialDatesSyncResult.Success
}
/**
* Set the reminder default for a type's managed calendar and apply it to
* **all** its existing events too (not just future ones) — for managed
* calendars the reminder is a calendar-level setting. Persists the per-calendar
* all-day override so new events keep matching. No-op if the calendar for
* [type] doesn't exist yet.
*/
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
prefs.setCalendarAllDayReminderOverride(calendarId, override)
val minutes = when (override) {
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
CalendarReminderOverride.None -> emptyList()
is CalendarReminderOverride.Minutes -> override.minutes
}
calendars.applyManagedCalendarReminders(
calendarId = calendarId,
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
minutes = minutes,
)
}
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
suspend fun teardown() = lifecycleMutex.withLock {
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 {
// reconcileCalendars persists the id — the single place ids are recorded.
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
// 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>,
template: String,
showYear: Boolean,
reminderCtx: ReminderContext,
) {
val built = contactsOfType.map { sd ->
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
}
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
val formByUid = built.associate { it.desired.uid to it.form }
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,
template: String,
showYear: Boolean,
reminderCtx: ReminderContext,
): BuiltManagedEvent {
val anchor = sd.anchorDate()
val start = LocalDateTime(anchor, LocalTime(0, 0))
// The source year is static and correct on every occurrence (unlike age).
val year = if (showYear) sd.year else null
val title = renderSpecialDateTitle(template, sd.displayName, year)
val form = EventForm(
calendarId = calendarId,
title = title,
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 = sd.managedUid(),
title = title,
dtStartMillis = dtStartMillis,
rrule = YEARLY_RRULE,
),
form = form,
)
}
// Managed events are always all-day, so only the all-day defaults apply.
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
)
private data class ReminderContext(
val allDayGlobal: List<Int>,
val allDayOverrides: Map<Long, List<Int>>,
val allDayTimeMinutes: Int,
) {
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
fun resolveAllDay(calendarId: Long): List<Int> =
allDayOverrides[calendarId] ?: allDayGlobal
}
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)
}

View File

@@ -14,10 +14,6 @@ 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
@@ -49,18 +45,6 @@ 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

View File

@@ -1,57 +0,0 @@
package de.jeanlucmakiola.calendula.data.fonts
import android.content.Context
import android.net.Uri
import de.jeanlucmakiola.calendula.domain.FontRole
import java.io.File
import java.util.Locale
import android.graphics.fonts.Font as PlatformFont
/**
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
* system file picker is copied into the app's private storage — one file per
* [FontRole] — so the selection survives even if the original is later moved or
* deleted, and never leaves the app. The chosen file is validated as a real font
* before it replaces the previous one, so a bad pick can't wedge the app-wide
* typography.
*/
object CustomFontStore {
private fun dir(context: Context): File = File(context.filesDir, "fonts")
/** The stored font file for [role] (may not exist yet). */
fun file(context: Context, role: FontRole): File =
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
fun exists(context: Context, role: FontRole): Boolean =
file(context, role).let { it.exists() && it.length() > 0 }
/**
* Copy [uri] into per-role storage, first validating that it parses as a
* font. Returns true on success; on any failure the previous file is left
* untouched and false is returned (the caller keeps the old selection).
*/
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
val target = file(context, role)
target.parentFile?.mkdirs()
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
tmp.outputStream().use { output -> input.copyTo(output) }
} ?: return false
// Font.Builder throws IOException for anything that isn't a valid
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
PlatformFont.Builder(tmp).build()
if (target.exists() && !target.delete()) return false
tmp.renameTo(target)
} catch (_: Exception) {
tmp.delete()
false
}
}
/** Forget the custom font for [role] (used when switching away from it). */
fun clear(context: Context, role: FontRole) {
file(context, role).delete()
}
}

View File

@@ -8,15 +8,12 @@ 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.FontRole
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
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -109,47 +106,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
}
/**
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
* strings — "system", "custom", or a bundled font's token — resolved to a
* FontFamily at the theme layer; an unknown token degrades to the default.
*/
val brandFont: Flow<String> = store.data.map { prefs ->
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
}
val plainFont: Flow<String> = store.data.map { prefs ->
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
}
suspend fun setBrandFont(token: String) {
store.edit { it[BRAND_FONT_KEY] = token }
}
suspend fun setPlainFont(token: String) {
store.edit { it[PLAIN_FONT_KEY] = token }
}
/**
* A per-role bump counter for the user-loaded custom font. Re-importing
* overwrites the same file under the same "custom" token, so the token alone
* can't signal the change; this stamp — carried into AppFontSettings — breaks
* value equality so the resolved FontFamily (and its previews) refresh. A
* missing key is 0 (existing installs); the first import bumps it to 1.
*/
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
suspend fun bumpCustomFontStamp(role: FontRole) {
val key = when (role) {
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
}
store.edit { it[key] = (it[key] ?: 0) + 1 }
}
suspend fun setWeekStart(pref: WeekStartPref) {
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
}
@@ -263,19 +219,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
}
/**
* Atomic read-modify-write of the quick-switch config: [transform] sees the
* value currently stored, parsed inside the edit block, not the async-echoed
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
* instead of each re-serialising a stale copy of the other field over it.
*/
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
store.edit { prefs ->
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
}
}
/**
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
* are appended in default order and unknown names dropped. Absent key means
@@ -514,95 +457,6 @@ 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 `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
}
/** Outcome of the last mirror sync, for the settings status line. */
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(',')
@@ -647,10 +501,6 @@ class SettingsPrefs @Inject constructor(
companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
@@ -695,30 +545,9 @@ 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_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
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. */
@@ -737,18 +566,6 @@ sealed interface 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
* if [calendarId] has one for this event kind, otherwise the global default for
@@ -834,15 +651,7 @@ private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
// Only the deliberate "none" sentinel means an explicit no-reminder
// override (empty list); a non-sentinel value that parses to no valid
// minutes is garbage and drops the entry, so the calendar inherits the
// global default rather than silently reading as "no reminder".
when (val value = parts[1]) {
NONE -> id to emptyList()
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
?: return@mapNotNull null
}
id to parts[1].toReminderList()
}.toMap()
}

View File

@@ -17,28 +17,16 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* True when [this] alert belongs to a calendar the user disabled in-app, so its
* reminder must be suppressed (mirroring the event filtering in
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
* never treated as disabled. This is the one predicate the disabled-calendar
* gate is built from: [postableAlerts] here and the choke point in
* [ReminderNotifier.post] both use it.
*/
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
calendarId != 0L && calendarId in disabledCalendarIds
/**
* The due alerts that should actually surface as notifications: everything
* except alerts whose calendar the user has disabled in-app. The caller still
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
* provider.
* except alerts whose calendar the user has disabled in-app (mirroring the
* event filtering in CalendarRepositoryImpl). The caller still marks the full
* due set fired, so suppressed alerts are not re-broadcast by the provider.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
/**
* Becomes the app that turns the calendar provider's reminder alarms into
@@ -57,7 +45,6 @@ class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
@Inject lateinit var suppressedStore: SuppressedReminderStore
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
@@ -73,16 +60,11 @@ class EventReminderReceiver : BroadcastReceiver() {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
val disabled = calendarPrefs.disabledCalendarIds.first()
val postable = postableAlerts(due, disabled)
// Suppress reminders for disabled calendars, but still mark
// every due alert fired so the provider stops re-broadcasting
// the suppressed ones. Stash those suppressed alerts so
// re-enabling their calendar can recover them (they would
// otherwise stay STATE_FIRED forever with no re-scan).
postable.forEach { notifier.post(it) }
// the suppressed ones.
postableAlerts(due, disabled).forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
suppressedStore.stash(due - postable.toSet(), now)
suppressedStore.purgeExpired(now)
}
} finally {
pendingResult.finish()

View File

@@ -14,7 +14,6 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import kotlinx.coroutines.flow.first
@@ -34,7 +33,6 @@ import javax.inject.Singleton
class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -46,11 +44,6 @@ class ReminderNotifier @Inject constructor(
}
suspend fun post(alert: ReminderAlert) {
// The single choke point for the disabled-calendar gate: it covers both
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
// (ReminderActionReceiver), so a calendar disabled after a snooze no
// longer notifies — without either receiver duplicating the check.
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first()

View File

@@ -1,130 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Still relevant while the event has not ended: a reminder for an event that is
* already over is pointless to re-surface. Falls back to the begin time when the
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
*/
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* Local stash of reminder alerts that fired while their calendar was disabled
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
* (so the provider stops re-broadcasting the suppressed ones), which would
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
* the user re-enables the calendar before the event is over.
*
* Persisted in the shared preferences DataStore as a set of self-describing
* strings (one per alert); the stash never reaches a screen, so there is no
* domain model. Entries whose event has already ended are dropped on the next
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
*/
@Singleton
class SuppressedReminderStore @Inject constructor(
private val store: DataStore<Preferences>,
) {
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
if (alerts.isEmpty()) return
store.edit { prefs ->
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
alerts.forEach { byId[it.alertId] = it }
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
}
}
/**
* Remove and return the still-relevant stashed alerts belonging to any of
* [calendarIds]; drops expired entries for every calendar in passing.
*/
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
val recovered = mutableListOf<ReminderAlert>()
store.edit { prefs ->
val kept = decodeAll(prefs).filter { alert ->
when {
!alert.isRelevantAt(nowMillis) -> false // expired: drop
alert.calendarId in calendarIds -> { recovered += alert; false }
else -> true
}
}
prefs.putStash(kept)
}
return recovered
}
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
suspend fun purgeExpired(nowMillis: Long) {
store.edit { prefs ->
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
}
}
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
}
private companion object {
val KEY = stringSetPreferencesKey("suppressed_reminders")
}
}
// One stash entry as a delimited string. The '|' separator is safe because every
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
// a null location is stored as a distinct sentinel that Base64 also never yields.
private const val FIELD_SEP = "|"
private const val NULL_LOCATION = "-"
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
alert.alertId.toString(),
alert.eventId.toString(),
alert.calendarId.toString(),
alert.beginMillis.toString(),
alert.endMillis.toString(),
if (alert.isAllDay) "1" else "0",
alert.title.toBase64(),
alert.location?.toBase64() ?: NULL_LOCATION,
).joinToString(FIELD_SEP)
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
internal fun decodeStashEntry(raw: String): ReminderAlert? {
val parts = raw.split(FIELD_SEP)
if (parts.size != 8) return null
return try {
ReminderAlert(
alertId = parts[0].toLong(),
eventId = parts[1].toLong(),
calendarId = parts[2].toLong(),
beginMillis = parts[3].toLong(),
endMillis = parts[4].toLong(),
title = parts[6].fromBase64(),
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
isAllDay = parts[5] == "1",
)
} catch (e: NumberFormatException) {
null
} catch (e: IllegalArgumentException) { // bad Base64
null
}
}
private fun String.toBase64(): String =
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
private fun String.fromBase64(): String =
String(Base64.getDecoder().decode(this), Charsets.UTF_8)

View File

@@ -0,0 +1,179 @@
package de.jeanlucmakiola.calendula.domain
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Curates an account's published event palette for the colour picker.
*
* Sync adapters differ wildly in what they publish: Google exposes a
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
* spelling pairs) and dozens of visually indistinguishable whites and grays
* (#22).
*
* Crucially, curation runs against the colour the picker actually *paints*, not
* the raw provider value. The picker softens every swatch through [pastelArgb]:
* it pins lightness to a constant and caps saturation, so the raw palette's
* lightness axis is invisible on screen. Two raw colours that look different —
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
* grays, white) paints as the same pale tint. Judging distinctness in raw
* space, as before, left near-identical painted swatches and stranded the
* neutrals as a run of look-alike "pinks" at the end of the grid.
*
* Three steps, all in painted space:
* 1. Collapse swatches that paint identically to one (alphabetically-first key
* wins, deterministically) — this folds aliases, dark/light shades of a
* hue, and all the neutrals together.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
* then thinned to visually distinct colours: most vivid first, a colour is
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
* colour already kept. Small palettes are already curated by their adapter
* and pass through whole.
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
* with the wheel cut at its single widest empty gap so the one unavoidable
* seam lands in dead space and no hue family is torn across both ends.
*
* Every surviving option keeps its provider [EventColorOption.key], so a pick
* still round-trips through sync.
*/
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
val painted = sortedBy { it.key }
.distinctBy { pastelArgb(it.argb) }
.map { it to Lab.of(pastelArgb(it.argb)) }
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
painted
} else {
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
}
return orderAroundWheel(kept).map { (option, _) -> option }
}
/**
* Orders swatches continuously around the (painted) hue wheel, then cuts the
* circle at its widest angular gap so the single seam lands in empty space
* instead of mid-family. Saturation breaks ties, vivid first.
*/
private fun orderAroundWheel(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
if (swatches.size < 2) return swatches
val byHue = swatches.sortedWith(
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
)
// Split the wheel after the largest empty arc between neighbouring hues;
// the default is the wrap gap (last hue back round to the first), i.e. the
// familiar 0→360 order, and we only rotate away from it for a wider void.
var cutAfter = byHue.lastIndex
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
for (i in 0 until byHue.lastIndex) {
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
if (gap > widestGap) {
widestGap = gap
cutAfter = i
}
}
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
}
/** Greedy max-distance filter: vivid colours stake out clusters first. */
private fun thin(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
for (candidate in byVividness) {
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
}
return kept
}
/**
* The softening the colour picker paints over every swatch (mirrored by
* ui/pastelize, which wraps this): keep the hue, scale and clamp saturation
* into a gentle band, and pin value to a constant so nothing screams and
* everything reads on the surface. Value is fixed here so curation is
* theme-independent — only hue and saturation distinguish painted swatches.
*/
fun pastelArgb(rawArgb: Int): Int {
val r = ((rawArgb shr 16) and 0xFF) / 255f
val g = ((rawArgb shr 8) and 0xFF) / 255f
val b = (rawArgb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
val hue = when {
delta == 0f -> 0f
max == r -> 60f * (((g - b) / delta) % 6f)
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}.let { if (it < 0f) it + 360f else it }
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
val s = sat.coerceIn(0.25f, 0.65f)
val v = PASTEL_VALUE
val c = v * s
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
val m = v - c
val (rr, gg, bb) = when {
hue < 60f -> Triple(c, x, 0f)
hue < 120f -> Triple(x, c, 0f)
hue < 180f -> Triple(0f, c, x)
hue < 240f -> Triple(0f, x, c)
hue < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
}
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
private const val PASTEL_VALUE = 0.82f
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
private const val CURATION_TRIGGER_SIZE = 36
/** Minimum CIE76 ΔE between surviving painted swatches. */
private const val MIN_DELTA_E = 13.0
/**
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
* is dropped rather than shown as pale filler.
*/
private const val PASTEL_CHROMA_FLOOR = 22.0
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
private class Lab(val l: Double, val a: Double, val b: Double) {
val chroma: Double get() = hypot(a, b)
/** Hue angle in degrees, 0360, around the Lab a-b plane. */
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
fun deltaE(other: Lab): Double =
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
companion object {
fun of(argb: Int): Lab {
fun linear(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
val r = linear(16)
val g = linear(8)
val b = linear(0)
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
val fy = f(y)
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
}
}
}

View File

@@ -1,8 +0,0 @@
package de.jeanlucmakiola.calendula.domain
/**
* The two Material 3 typeface roles a user can set independently (issue #19):
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
* title/body/label styles (readability). Each defaults to the system typeface.
*/
enum class FontRole { BRAND, PLAIN }

View File

@@ -26,14 +26,6 @@ data class CalendarSource(
* owns for its own calendars). Always null for synced calendars.
*/
val description: String? = null,
/**
* A special-dates mirror calendar the app manages (birthdays/anniversaries
* from contacts). Its events' title/date/recurrence are owned by the sync,
* so it's hidden from the new-event calendar picker and its events lock those
* fields in the editor. Recognised by a durable provider marker, so it holds
* even after a backup restore clears the app's stored ids.
*/
val isManaged: Boolean = false,
)
data class EventInstance(

View File

@@ -1,122 +0,0 @@
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()
}

View File

@@ -1,65 +0,0 @@
package de.jeanlucmakiola.calendula.domain.contacts
import kotlinx.datetime.LocalDate
/**
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
* only ever reconciles — and never deletes — events it actually owns.
*/
const val MANAGED_UID_PREFIX = "contact-"
/**
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
* contact date. Stable across syncs (the reconciliation key), namespaced by
* type so the same contact's birthday and anniversary never collide, and — when
* a [discriminator] is given — by it too, so two Custom dates on one contact
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
* each other.
*/
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
}
/**
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
* date adds a discriminator (its label, else its month-day) so distinct custom
* dates on one contact don't collapse into a single event.
*/
fun ContactSpecialDate.managedUid(): String =
managedEventUid(type, lookupKey, customDiscriminator())
private fun ContactSpecialDate.customDiscriminator(): String? =
if (type == SpecialDateType.Custom) {
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
} else {
null
}
/**
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
* 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)
/**
* Render a title [template] for a contact, substituting `{name}` and `{year}`
* (the source year — a birthday's birth year or an anniversary's start year;
* empty when [year] is null). Unlike an age, the year is static and correct on
* every occurrence of the yearly event. Collapses the whitespace an empty
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
* "Jane's birthday" when no year is known.
*/
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
template
.replace("{name}", name)
.replace("{year}", year?.toString().orEmpty())
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
.replace(Regex("""\(\s*\)"""), "")
.replace(Regex("""\s+"""), " ")
.trim()

View File

@@ -19,8 +19,6 @@ 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.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@@ -51,12 +49,6 @@ 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 (context.hasContactsPermission()) {
SpecialDatesScheduler.runNow(context, foreground = true)
}
}
}
lifecycle.addObserver(obs)

View File

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

View File

@@ -14,8 +14,6 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
@@ -47,8 +45,6 @@ class CalendarsViewModel @Inject constructor(
private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
private val suppressedStore: SuppressedReminderStore,
private val notifier: ReminderNotifier,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -148,10 +144,7 @@ class CalendarsViewModel @Inject constructor(
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id
if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(setOf(id))
}
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
@@ -164,29 +157,7 @@ class CalendarsViewModel @Inject constructor(
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + ids else current - ids.toSet()
if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(current intersect ids.toSet())
}
}
}
/**
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
* still relevant (event not yet over), then drop them from the stash. Runs
* after the disabled set is written, so the notifier's own disabled gate lets
* them through. Best-effort at re-enable time: it mirrors the receiver gates
* (reminders on + postable), and there is no later re-scan, so alerts left
* unposted because those gates are closed are simply released.
*/
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
if (reEnabledIds.isEmpty()) return
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
if (recovered.isNotEmpty() &&
settingsPrefs.remindersEnabled.first() &&
notifier.canPost()
) {
recovered.forEach { notifier.post(it) }
if (next != current) prefs.setDisabledCalendarIds(next)
}
}

View File

@@ -15,17 +15,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.pastelArgb
/**
* Soften a raw calendar color toward a pastel that fits the active theme.
* - Keeps the hue (so users still recognise their calendars)
* - Caps saturation so harsh provider colors stop screaming
* - Pins value/brightness to a band that reads on both light and dark surfaces
*
* The hue/saturation shaping lives in [pastelArgb] so the event-colour picker's
* curation reasons about the exact colour painted here (#22); this only picks
* the theme's brightness.
*/
fun pastelize(rawArgb: Int, dark: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
android.graphics.Color.colorToHSV(pastelArgb(rawArgb), hsv)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}

View File

@@ -67,3 +67,16 @@ 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

@@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -58,16 +57,10 @@ fun positionOf(index: Int, count: Int): Position = when {
}
/**
* The app's standard full-screen list scaffold. By default a collapsing
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
* the content scrolls — used by Settings and the calendar manager, where the
* large header sets the page. Content is a scrollable column that feeds the
* toolbar via nested scroll.
*
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
* title sits in the bar from the start, with no expanded header to scroll past.
* Preferred for selection pickers, where the tall header is just wasted space
* above the options.
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
* whose title shrinks into the bar (next to the back button) as the content
* scrolls. Content is a scrollable column that feeds the toolbar via nested
* scroll. Used by Settings and the calendar manager so they share one shell.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -75,16 +68,12 @@ fun CollapsingScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
largeTopBar: Boolean = true,
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
val scrollBehavior = if (largeTopBar) {
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
} else {
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
}
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
@@ -92,34 +81,22 @@ fun CollapsingScaffold(
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
val navigationIcon = @Composable {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
}
val colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
LargeTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
actions = actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
if (largeTopBar) {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = colors,
)
} else {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = colors,
)
}
},
snackbarHost = snackbarHost,
) { innerPadding ->

View File

@@ -36,7 +36,6 @@ 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,
@@ -44,17 +43,15 @@ fun InlineTextField(
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
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
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,

View File

@@ -24,7 +24,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -46,9 +45,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
/**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
* pinned single-line title rather than the large collapsing header — a picker is
* a short list, so the tall header would only be empty space to scroll past.
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
* picker is visually identical to a Settings sub-page and uses the full width.
* [content] places the connected grouped rows; selecting one calls [onDismiss].
*/
@Composable
@@ -73,12 +71,7 @@ fun FullScreenPicker(
(view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
CollapsingScaffold(
title = title,
onBack = onDismiss,
largeTopBar = false,
content = content,
)
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
}
}
@@ -123,16 +116,12 @@ fun <T> OptionPicker(
* Reminder-default picker, full-screen and **multi-select**: each [presets]
* lead time (plus any chosen custom value) is a checkbox row that toggles
* independently, so a default can carry several reminders ("1 week before" *and*
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
* "No reminder" (both pickers, so the empty state stays visible and reachable)
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
* defers to the global default. Clearing the last checked time reverts to "Use
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
* on the global default (where empty legitimately means no reminder). A "Custom"
* row expands an inline number field plus a unit selector to add an arbitrary
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
* back.
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
* default reminder" row above the list; choosing it defers to the global default
* and clears the checked times. With nothing checked and inherit off, the choice
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
* expands an inline number field plus a unit selector to add an arbitrary lead
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
*/
@Composable
fun ReminderDefaultPicker(
@@ -143,54 +132,48 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit,
) {
// Optimistic local state: once the user edits, the chosen override is
// authoritative while the picker is open, so quick successive toggles compose
// on each other instead of racing the round-trip through the settings flow
// (which would drop a toggle made before the previous write echoes back).
// Optimistic local state: the chosen override is authoritative while the
// picker is open, so quick successive toggles compose on each other instead
// of racing the round-trip through the settings flow (which would drop a
// toggle made before the previous write echoes back). Seeded once on open;
// the picker leaves composition on dismiss, so reopening re-reads [selected].
var current by remember { mutableStateOf(selected) }
// Whether the user has toggled anything yet. Until then, [current] keeps
// mirroring [selected]: a picker that first composed against the settings
// flow's initialValue (empty — reachable after process-death restore straight
// onto the Notifications page, before the CalendarProvider-gated combine
// resolves) then adopts the real stored default instead of persisting empty
// on the first tap. After the first edit, local state is authoritative.
var userEdited by remember { mutableStateOf(false) }
LaunchedEffect(selected) {
if (!userEdited) current = selected
}
val inherits = current is CalendarReminderOverride.Inherit
val isNone = current is CalendarReminderOverride.None
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
// Custom (non-preset) lead times seen this session, so unchecking one keeps
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
// which would strand a hand-entered value with no way to re-check it.
val seenCustom = remember { mutableSetOf<Int>() }
seenCustom += selectedMinutes.filter { it !in presets }
// Presets plus every custom value seen this session, each as its own row.
val rows = presets + seenCustom.sorted()
// Presets plus any selected custom (non-preset) values, each as its own row.
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
fun apply(override: CalendarReminderOverride) {
userEdited = true
current = override
onSelect(override)
}
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
// Emit the override for a new set of minutes: empty collapses to None, which
// (with inherit unchosen) reads as an explicit "no reminder".
fun emit(minutes: List<Int>) {
val norm = minutes.distinct().sorted()
apply(
if (norm.isEmpty()) {
CalendarReminderOverride.None
} else {
CalendarReminderOverride.Minutes(norm)
},
)
}
fun toggle(minute: Int) =
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
FullScreenPicker(title = title, onDismiss = onDismiss) {
// Exclusive choices in their own group above the multi-select list, since
// each is mutually exclusive with picking specific times: "use default"
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
// empty state stays visible and deliberately reachable).
// Exclusive "use default" choice (per-calendar only): its own group above
// the multi-select list, since inheriting is mutually exclusive with
// picking specific times.
if (allowInherit) {
GroupedRow(
title = stringResource(R.string.reminder_use_default),
position = Position.Top,
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
@@ -199,19 +182,8 @@ fun ReminderDefaultPicker(
},
onClick = { apply(CalendarReminderOverride.Inherit) },
)
Spacer(Modifier.height(24.dp))
}
GroupedRow(
title = stringResource(R.string.reminder_none),
position = if (allowInherit) Position.Bottom else Position.Alone,
selected = isNone,
trailing = if (isNone) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(CalendarReminderOverride.None) },
)
Spacer(Modifier.height(24.dp))
val rowCount = rows.size + 1 // + the custom row
rows.forEachIndexed { index, minute ->
val checked = minute in selectedMinutes
@@ -250,27 +222,6 @@ fun ReminderDefaultPicker(
}
}
/**
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
* silently wipe the calendar's default, and to explicit
* [CalendarReminderOverride.None] on the global default (where empty legitimately
* means no reminder). Pure so it can be unit-tested.
*/
internal fun reminderOverrideForMinutes(
minutes: List<Int>,
allowInherit: Boolean,
): CalendarReminderOverride {
val norm = minutes.distinct().sorted()
return when {
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
allowInherit -> CalendarReminderOverride.Inherit
else -> CalendarReminderOverride.None
}
}
/**
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
* row above it (matching the grouped-row system, so the two read as one

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -44,10 +43,8 @@ private val RowGap: Dp = 2.dp
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
* uniform gap, so a row's target slot is simply how many whole pitches it has
* been dragged. The held row follows the finger while the others slide out of
* its way (animated); on release the order is committed immediately with a
* single [onReorder] call, then the held row eases into its new slot as a
* purely visual settle — safe to interrupt with another drag, since the
* commit itself never waits on it.
* its way (animated); on release the held row settles into its slot, then the
* order is committed with a single [onReorder] call.
*
* [rowContent] receives the [Position] for the row's place in the order (to reuse
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
@@ -69,29 +66,16 @@ fun <T> ReorderableColumn(
var order by remember(items) { mutableStateOf(items) }
var draggedKey by remember { mutableStateOf<Any?>(null) }
// Live translation of the held row from its slot (px); also drives the
// release settle — re-based onto the row's new slot once the order commits
// (see onDragEnd), then eased down to zero.
// release settle, animated back onto a slot before the order is committed.
var dragOffset by remember { mutableFloatStateOf(0f) }
// The running release/cancel settle, cancelled if a new drag pre-empts it.
// Purely visual — the order commit (see onDragEnd) never depends on it.
var settleJob by remember { mutableStateOf<Job?>(null) }
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
// Whole slots dragged → the slot the held row currently hovers over. Derived
// so recomposition only fires when the *target slot* actually changes, not on
// every dragged pixel: dragOffset moves every frame during a drag, but the
// held row's own translation is already applied in the draw phase via
// graphicsLayer below, so only the neighbours' shift (which depends on this)
// needs to recompose, and only when a slot boundary is actually crossed.
// (Read as a plain val, not `by`, below: a delegated property has a custom
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
// the loop, so the derived value is captured into a real local instead.)
val targetIndex = remember(items, pitchPx) {
derivedStateOf {
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
}
}.value
// Whole slots dragged → the slot the held row currently hovers over.
val targetIndex = draggedIndex?.let {
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
order.forEachIndexed { index, item ->
@@ -128,25 +112,16 @@ fun <T> ReorderableColumn(
val from = order.indexOfFirst { keyOf(it) == key }
if (from < 0) return@detectDragGestures
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
if (to != from) {
// Commit synchronously and unconditionally, before the settle
// animation below runs — the commit must not depend on that
// coroutine reaching its end, or a new drag starting within the
// ~160ms settle window would cancel it and silently revert an
// already-finished reorder.
order = order.toMutableList().apply { add(to, removeAt(from)) }
onReorder(order)
// The row now lays out at slot `to` instead of `from`; re-base
// the live offset onto that new slot (same visual position,
// expressed relative to the new one) so the settle below eases
// it the rest of the way instead of jumping.
dragOffset -= (to - from) * pitchPx
}
settleJob = scope.launch {
// Purely visual from here: ease the held row onto its slot, then
// release the drag state. Safe to cancel — the order was already
// committed above.
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
// Settle the held row onto its target slot, then commit —
// resetting offset and slot in the same frame, so nothing jumps.
Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) {
dragOffset = value
}
if (to != from) {
order = order.toMutableList().apply { add(to, removeAt(from)) }
onReorder(order)
}
draggedKey = null
dragOffset = 0f
}

View File

@@ -250,18 +250,14 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_edit),
)
}
// Managed special-dates events have no delete — the sync
// would resurrect them; the date is removed at the contact.
if (!s.isManaged) {
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
}
},

View File

@@ -15,12 +15,6 @@ sealed interface EventDetailUiState {
val calendarName: String?,
/** Whether the owning calendar allows modifying events (shows edit/delete). */
val canModify: Boolean = false,
/**
* The event belongs to a managed special-dates calendar: its editable
* fields (reminders, notes) can still be edited, but delete is hidden —
* the sync would just resurrect it. Contact dates are removed at the source.
*/
val isManaged: Boolean = false,
) : EventDetailUiState
}

View File

@@ -154,7 +154,6 @@ class EventDetailViewModel @Inject constructor(
detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
isManaged = calendar?.isManaged == true,
)
} catch (e: CancellationException) {
throw e

View File

@@ -37,7 +37,6 @@ import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable
@@ -120,16 +119,13 @@ import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard
@@ -439,10 +435,6 @@ 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) }
@@ -527,7 +519,6 @@ 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)
@@ -540,17 +531,6 @@ 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))
@@ -569,11 +549,7 @@ private fun EventEditContent(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Switch(
checked = form.isAllDay,
onCheckedChange = viewModel::setAllDay,
enabled = !locked,
)
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
}
},
) {
@@ -585,7 +561,6 @@ private fun EventEditContent(
locale = locale,
onDateClick = { picker = PickerTarget.StartDate },
onTimeClick = { picker = PickerTarget.StartTime },
enabled = !locked,
)
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
@@ -595,7 +570,6 @@ 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))
@@ -726,7 +700,7 @@ private fun EventEditContent(
EditCard(
icon = Icons.Default.Repeat,
iconContentDescription = null,
onClick = if (locked) null else ({ showRecurrencePicker = true }),
onClick = { showRecurrencePicker = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -969,7 +943,7 @@ private fun EventEditContent(
}
if (showCalendarPicker) {
CalendarPicker(
CalendarPickerDialog(
calendars = state.calendars,
selectedId = form.calendarId,
onSelect = {
@@ -1738,18 +1712,26 @@ private fun ColorPickerDialog(
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (palette.isNotEmpty()) {
// The event's current colour may not be in the curated
// palette (thinned near-duplicate, or a raw colour set
// elsewhere) — append it so the selection ring has a home.
val swatches = palette.map { it.argb }.let {
if (selected != null && selected !in it) it + selected else it
}
ColorSwatchRow(
colors = palette.map { it.argb },
colors = swatches,
selected = selected,
onSelect = { argb ->
palette.firstOrNull { it.argb == argb }
?.let { onPickKey(it.key, it.argb) }
val option = palette.firstOrNull { it.argb == argb }
// The appended current colour has no key to write —
// it is already the event's colour, so just close.
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
},
dark = dark,
)
} else {
ColorSwatchRow(
colors = CalendarColorPalette.all,
colors = CALENDAR_COLOR_PALETTE,
selected = selected,
onSelect = onPickRaw,
dark = dark,
@@ -1884,7 +1866,6 @@ 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()
@@ -1898,7 +1879,6 @@ private fun InlineField(
textStyle = textStyle,
singleLine = singleLine,
minLines = minLines,
enabled = enabled,
keyboardType = keyboardType,
)
}
@@ -1913,7 +1893,6 @@ private fun ScheduleRow(
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
isError: Boolean = false,
enabled: Boolean = true,
) {
val dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -1923,12 +1902,11 @@ 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; 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
// detail screen; errors flip them to the error colour.
val valueColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1945,7 +1923,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(enabled = enabled, onClick = onDateClick)
.clickable(onClick = onDateClick)
.padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!isAllDay) {
@@ -1954,71 +1932,41 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(enabled = enabled, onClick = onTimeClick)
.clickable(onClick = onTimeClick)
.padding(vertical = 8.dp, horizontal = 6.dp),
)
}
}
}
/**
* Full-screen calendar picker: a scrollable list of every writable calendar,
* grouped under its owning account like the visibility filter and calendar
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
* list past ~9 rows so accounts with many calendars had unreachable entries
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
*/
@Composable
private fun CalendarPicker(
private fun CalendarPickerDialog(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// Group by owning account (name, else type, else the calendar's own name),
// preserving the provider's order within and across groups — the same
// grouping [groupByAccount] applies for the drawer filter.
val groups = remember(calendars) {
calendars
.groupBy {
it.accountName.takeIf(String::isNotBlank)
?: it.accountType.takeIf(String::isNotBlank)
?: it.displayName
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_detail_calendar)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
calendars.forEach { calendar ->
OptionCard(
label = calendar.displayName,
onClick = { onSelect(calendar.id) },
icon = Icons.Default.CalendarMonth,
// The calendar's own colour carries its identity.
iconTint = pastelize(calendar.color, dark),
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
selected = calendar.id == selectedId,
)
}
}
.toList()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
onDismiss = onDismiss,
) {
groups.forEach { (account, cals) ->
Text(
text = account,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
cals.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}

View File

@@ -29,12 +29,6 @@ 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

View File

@@ -122,24 +122,18 @@ class EventEditViewModel @Inject constructor(
val autofocusTitle: Boolean,
)
/** Every calendar the provider exposes; the source for both lists below. */
private val allCalendars: Flow<List<CalendarSource>> =
repository.calendars().catch { emit(emptyList()) }
/**
* Writable calendars — the only valid event targets. Disabled calendars are
* excluded, so you can't create into a calendar you've removed from the app;
* a last-used preselect landing on a now-disabled calendar falls back to the
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
* Managed special-dates calendars are excluded too: their events are owned by
* the contact sync, which would delete any user event created there.
*/
private val writableCalendars: Flow<List<CalendarSource>> = combine(
allCalendars,
repository.calendars(),
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
}
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(
@@ -171,33 +165,21 @@ class EventEditViewModel @Inject constructor(
::ExternalInputs,
).flowOn(io),
colorPalette,
allCalendars,
) { local, external, palette, allCalendars ->
) { local, external, palette ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
// Editing an event in a managed calendar locks its synced fields. Keyed
// off the calendar's durable marker, not a stored id, so it holds after a
// backup restore too.
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
// The picker offers writable calendars only; when editing a managed event
// its own (excluded) calendar is added back so the row still names it.
val pickerCalendars =
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
else external.writable
val visibleFields = external.defaultFields + local.revealed
EventEditUiState(
form = resolved,
calendars = pickerCalendars,
calendars = external.writable,
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
isManaged = isManaged,
autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change.
@@ -435,10 +417,7 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Saved
return
}
// Managed events are a yearly series whose editable fields (reminders,
// notes, location) live on the series row — never offer the scope dialog,
// which would split the series into an exception the sync then reverts.
if (target != null && target.original.rrule != null && !current.isManaged) {
if (target != null && target.original.rrule != null) {
_saveState.value = SaveUiState.AwaitingScope
return
}

View File

@@ -7,11 +7,9 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -39,9 +37,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Check
@@ -57,7 +53,6 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -67,7 +62,6 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -82,7 +76,6 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -93,30 +86,24 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
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
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
@@ -135,17 +122,13 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalTime
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, Views, EventForm, Notifications, SpecialDates }
private enum class SettingsSection { Appearance, Views, EventForm, Notifications }
/**
* Token-based accent for a leading icon chip (container / on-container pair).
@@ -209,19 +192,7 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
NotificationsScreen(
state = state,
viewModel = viewModel,
onBack = { section = null },
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
)
}
AnimatedVisibility(
visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
}
}
@@ -275,13 +246,6 @@ 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
@@ -534,18 +498,6 @@ private fun AppearanceScreen(
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) }
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
// A picked file that didn't parse as a font: tell the user and keep the old choice.
val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
LaunchedEffect(Unit) {
viewModel.fontImportFailed.collect {
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
}
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
@@ -582,24 +534,6 @@ private fun AppearanceScreen(
Spacer(Modifier.height(16.dp))
// Fonts — the two Material typeface roles, each independently choosable
// (issue #19). Headings = brand (display/headline); body = plain
// (title/body/label). Both default to the system typeface.
GroupedRow(
title = stringResource(R.string.settings_font_headings),
summary = fontLabel(fonts.brand),
position = Position.Top,
onClick = { showBrandFont = true },
)
GroupedRow(
title = stringResource(R.string.settings_font_body),
summary = fontLabel(fonts.plain),
position = Position.Bottom,
onClick = { showPlainFont = true },
)
Spacer(Modifier.height(16.dp))
// Calendar — view, week, and timeline formatting
GroupedRow(
title = stringResource(R.string.settings_default_view),
@@ -691,28 +625,6 @@ private fun AppearanceScreen(
onDismiss = { showTheme = false },
)
}
if (showBrandFont) {
FontPicker(
title = stringResource(R.string.settings_font_headings),
role = FontRole.BRAND,
selected = fonts.brand,
stamp = fonts.brandStamp,
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
onDismiss = { showBrandFont = false },
)
}
if (showPlainFont) {
FontPicker(
title = stringResource(R.string.settings_font_body),
role = FontRole.PLAIN,
selected = fonts.plain,
stamp = fonts.plainStamp,
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
onDismiss = { showPlainFont = false },
)
}
if (showWeekStart) {
OptionPicker(
title = stringResource(R.string.settings_week_start),
@@ -800,7 +712,7 @@ private fun ViewsScreen(
ReorderableColumn(
items = config.order,
keyOf = { it },
onReorder = { viewModel.setQuickSwitchOrder(it) },
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
) { view, position, dragHandle, isDragging ->
val checked = view in config.enabled
ViewRow(
@@ -814,7 +726,10 @@ private fun ViewsScreen(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
onCheckedChange = { on ->
val enabled = if (on) config.enabled + view else config.enabled - view
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
},
)
},
)
@@ -1010,7 +925,6 @@ private fun NotificationsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
@@ -1135,25 +1049,6 @@ private fun NotificationsScreen(
Column {
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// A contact special-dates calendar owns its reminders in
// its own section — link there instead of an override.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName,
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
return@forEach
}
val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list
// of three (the card + the timed and all-day rows).
@@ -1276,287 +1171,6 @@ 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 (context.hasContactsPermission()) {
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) }
var reminderPickerType 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) {
// Per-type card: the toggle, and while on, its title format and its
// calendar-wide reminder.
SpecialDateType.entries.forEachIndexed { index, type ->
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
val on = type in state.types
GroupedRow(
title = stringResource(specialDateTypeLabel(type)),
position = if (on) Position.Top else Position.Alone,
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)
},
)
if (on) {
GroupedRow(
title = stringResource(R.string.settings_special_dates_template),
summary = state.titleTemplates[type].orEmpty(),
position = Position.Middle,
onClick = { editTemplate = type },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_reminders),
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
position = Position.Bottom,
onClick = { reminderPickerType = type },
)
}
}
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_special_dates_show_year),
summary = stringResource(R.string.settings_special_dates_show_year_hint),
position = Position.Top,
trailing = {
Switch(
checked = state.showYear,
onCheckedChange = viewModel::setSpecialDatesShowYear,
)
},
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
)
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 },
)
}
reminderPickerType?.let { type ->
ReminderDefaultPicker(
title = stringResource(R.string.settings_special_dates_reminders),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
// Managed calendars own their reminders outright — no "inherit global".
allowInherit = false,
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
onDismiss = { reminderPickerType = null },
)
}
}
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
@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 {
// The app's borderless input over a tonal surface (the dialog
// convention — see DialogControls), not Material's outlined field.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = text,
onValueChange = { text = it },
placeholder = stringResource(R.string.settings_special_dates_template),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.settings_special_dates_template_hint),
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
}
@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)
@@ -1640,11 +1254,22 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
return DateFormat.getTimeFormat(context).format(time)
}
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<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. */
@Composable
private fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
// 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. */
@@ -1707,152 +1332,6 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
},
)
/** The summary label for a stored font token (issue #19). */
@Composable
private fun fontLabel(token: String): String = when (token) {
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
?: stringResource(R.string.settings_font_system)
}
/**
* MIME types offered to the document picker so it lists only font files. Covers
* the modern `font/` types plus the legacy `application/` font aliases some
* providers still report. Anything that slips through is still validated by
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
*/
private val FONT_PICKER_MIME_TYPES = arrayOf(
"font/ttf",
"font/otf",
"font/sfnt",
"font/collection",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-sfnt",
"application/vnd.ms-opentype",
)
/**
* Full-screen font chooser for one [FontRole]: the system default, each bundled
* font (previewed in its own face), and "Choose file…" which opens the system
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
* a file is validated and imported by the caller, switching to the custom font
* on success.
*/
@Composable
private fun FontPicker(
title: String,
role: FontRole,
selected: String,
stamp: Int,
onSelect: (String) -> Unit,
onImport: (Uri) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) {
onImport(uri)
onDismiss()
}
}
// System default + the bundled fonts + the "Choose file…" row.
val rowCount = BundledFont.entries.size + 2
val isCustom = selected == FONT_CUSTOM_TOKEN
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
// refreshes the preview while plain recompositions reuse the cached family.
val customPreview = remember(role, isCustom, stamp) {
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
FontOptionRow(
label = stringResource(R.string.settings_font_system),
preview = FontFamily.Default,
selected = selected == FONT_SYSTEM_TOKEN,
position = positionOf(0, rowCount),
onClick = {
onSelect(FONT_SYSTEM_TOKEN)
onDismiss()
},
)
BundledFont.entries.forEachIndexed { index, font ->
FontOptionRow(
label = stringResource(font.labelRes),
preview = font.family,
selected = selected == font.token,
position = positionOf(index + 1, rowCount),
onClick = {
onSelect(font.token)
onDismiss()
},
)
}
FontOptionRow(
label = if (isCustom) {
stringResource(R.string.settings_font_custom_selected)
} else {
stringResource(R.string.settings_font_choose_file)
},
// A loaded font previews in its own face; otherwise show an upload cue.
preview = customPreview,
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
selected = isCustom,
position = positionOf(rowCount - 1, rowCount),
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
)
}
}
/**
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
* to preview), and a check when it's the current selection.
*/
@Composable
private fun FontOptionRow(
label: String,
preview: FontFamily?,
selected: Boolean,
position: Position,
onClick: () -> Unit,
leadingIcon: ImageVector? = null,
) {
GroupedRow(
title = label,
position = position,
selected = selected,
leading = {
if (preview != null) {
Text(
text = "Ag",
fontFamily = preview,
style = MaterialTheme.typography.titleLarge,
)
} else if (leadingIcon != null) {
Icon(imageVector = leadingIcon, contentDescription = null)
}
},
trailing = if (selected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = onClick,
)
}
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
private val WEEK_START_OPTIONS: List<WeekStartPref> =
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
@@ -8,7 +7,6 @@ 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
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -77,30 +75,4 @@ data class SettingsUiState(
* colour palette (the colour may then not survive their next sync).
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
/**
* Ids of the contact special-dates calendars. Their reminders are owned by
* the Contact special dates section, so the per-calendar override list links
* out to it instead of offering an override here.
*/
val managedCalendarIds: Set<Long> = emptySet(),
)
/**
* 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(),
/** The reminder choice per type's managed calendar (applied to all its events). */
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
/** Whether {year} resolves in titles (the source year is static and always correct). */
val showYear: Boolean = true,
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
val stalledPermission: Boolean = false,
/** Epoch millis of the last sync, or 0 if it has never run. */
val lastRun: Long = 0L,
)

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
@@ -11,38 +10,23 @@ 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.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
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.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -52,16 +36,12 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
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() {
@@ -107,9 +87,8 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
prefs.managedCalendarIds,
) { overrides, allDayOverrides, calendars, managedIds ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
combine(
prefs.defaultView,
@@ -154,7 +133,6 @@ class SettingsViewModel @Inject constructor(
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds,
)
}.stateIn(
scope = viewModelScope,
@@ -162,29 +140,6 @@ class SettingsViewModel @Inject constructor(
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
/**
* The app-wide custom-font choice (issue #19). Its own flow — both this
* screen and [MainActivity] (which builds the typography) read it
* independently of the main settings combine.
*/
val fontState: StateFlow<AppFontSettings> = combine(
prefs.brandFont,
prefs.plainFont,
prefs.brandFontStamp,
prefs.plainFontStamp,
) { brand, plain, brandStamp, plainStamp ->
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AppFontSettings(),
)
// Emitted when a picked font file couldn't be read as a font; the screen
// surfaces it and the previous selection stays put.
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
// updateAll calls at once — concurrent calls coalesce around a stale read
// and can leave the widget on the intermediate value. Held sequentially,
@@ -203,7 +158,6 @@ class SettingsViewModel @Inject constructor(
val timed: Map<Long, List<Int>>,
val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>,
val managedIds: Set<Long>,
)
private data class ViewSettings(
@@ -227,95 +181,6 @@ class SettingsViewModel @Inject constructor(
val drawerOrder: List<CalendarView>,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
combine(
prefs.specialDatesEnabled,
prefs.specialDatesTypes,
prefs.specialDatesTitleTemplates,
prefs.specialDatesShowYear,
prefs.specialDatesStatus,
) { enabled, types, templates, showYear, 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 ->
specialDatesSpec.resolveTitleTemplate(type, templates)
},
showYear = showYear,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun,
)
},
prefs.specialDatesCalendars,
prefs.perCalendarAllDayReminderOverride,
) { base, calendars, allDayOverrides ->
base.copy(
// An override is always seeded on creation; present-empty = None.
reminderChoices = calendars.mapValues { (_, calendarId) ->
allDayOverrides.choiceFor(calendarId)
},
)
}.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 setSpecialDatesShowYear(enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDatesShowYear(enabled)
SpecialDatesScheduler.runNow(appContext)
}
}
/**
* Set a type's managed-calendar reminder default and apply it to all its
* existing events (managed calendars own their reminders calendar-wide).
*/
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
}
/** Kick off an immediate reconcile (the "Sync now" action). */
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
@@ -324,35 +189,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDynamicColor(enabled) }
}
fun setFont(role: FontRole, token: String) {
viewModelScope.launch {
when (role) {
FontRole.BRAND -> prefs.setBrandFont(token)
FontRole.PLAIN -> prefs.setPlainFont(token)
}
}
}
/**
* Import a user-picked font file for [role]. Copies and validates it off the
* main thread; on success the role switches to the custom font, otherwise the
* previous choice is kept and [fontImportFailed] fires.
*/
fun importCustomFont(role: FontRole, uri: Uri) {
viewModelScope.launch {
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
if (ok) {
// Bump the stamp so replacing an already-active custom font (same
// file, same "custom" token) still breaks font-settings equality
// and refreshes the resolved typeface.
prefs.bumpCustomFontStamp(role)
setFont(role, FONT_CUSTOM_TOKEN)
} else {
_fontImportFailed.emit(Unit)
}
}
}
fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch {
prefs.setWeekStart(pref)
@@ -426,26 +262,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
/**
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
* read-modify-write so a concurrent reorder can't clobber this toggle (and
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
* re-checked inside the transform: the screen's own guard reads the
* async-echoed snapshot, so two quick disable-taps could both look allowed
* yet compose to a below-minimum cycle.
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
}
}
}
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
fun setQuickSwitchOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
fun setQuickSwitchConfig(config: QuickSwitchConfig) {
viewModelScope.launch { prefs.setQuickSwitchConfig(config) }
}
fun setDrawerViewOrder(order: List<CalendarView>) {

View File

@@ -1,129 +0,0 @@
package de.jeanlucmakiola.calendula.ui.theme
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.domain.FontRole
import java.io.File
/** Stored token for "use the device default typeface" — the default for both roles. */
const val FONT_SYSTEM_TOKEN = "system"
/** Stored token for "use the font the user loaded from a file" (per role). */
const val FONT_CUSTOM_TOKEN = "custom"
/**
* A font checked into the app that a user can pick without providing a file
* (issue #19). [token] is the stable value persisted in preferences; [family] is
* the resolved Compose family. Kept deliberately small and readability-focused.
*/
enum class BundledFont(
val token: String,
@StringRes val labelRes: Int,
val family: FontFamily,
) {
/** Purpose-built for low-vision legibility (Braille Institute). */
AtkinsonHyperlegible(
token = "atkinson_hyperlegible",
labelRes = R.string.font_atkinson_hyperlegible,
family = FontFamily(
Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal),
Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold),
),
),
/** A serif alternative; shipped as a variable font, weights via its wght axis. */
Lora(
token = "lora",
labelRes = R.string.font_lora,
family = FontFamily(
Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)),
Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)),
Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)),
),
),
/** A monospace option. */
JetBrainsMono(
token = "jetbrains_mono",
labelRes = R.string.font_jetbrains_mono,
family = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
),
);
companion object {
fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token }
}
}
private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight))
/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */
data class AppFontSettings(
val brand: String = FONT_SYSTEM_TOKEN,
val plain: String = FONT_SYSTEM_TOKEN,
// Per-role custom-font re-import stamp: bumped when the "custom" file is
// replaced, so a same-token re-import still breaks equality and refreshes the
// resolved family. 0 for existing installs / no import yet (issue #19).
val brandStamp: Int = 0,
val plainStamp: Int = 0,
)
/**
* Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean
* "leave the Material default" (the system typeface). A custom token whose file
* is missing or unreadable also resolves to null, so a deleted file degrades to
* the system font rather than crashing text layout.
*/
fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) {
FONT_SYSTEM_TOKEN -> null
FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role))
else -> BundledFont.fromToken(token)?.family
}
private fun customFontFamily(file: File): FontFamily? =
if (file.exists() && file.length() > 0) {
runCatching { FontFamily(Font(file)) }.getOrNull()
} else {
null
}
/**
* Build the app typography from the chosen [brand] and [plain] families. A null
* family leaves that role's Material default (system typeface) in place, so the
* common "system for both" case returns [CalendulaTypography] untouched. Brand
* drives display + headline; plain drives title + body + label (M3's two
* typeface roles).
*/
fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography {
if (brand == null && plain == null) return CalendulaTypography
val base = CalendulaTypography
fun TextStyle.withFamily(family: FontFamily?): TextStyle =
if (family != null) copy(fontFamily = family) else this
return base.copy(
displayLarge = base.displayLarge.withFamily(brand),
displayMedium = base.displayMedium.withFamily(brand),
displaySmall = base.displaySmall.withFamily(brand),
headlineLarge = base.headlineLarge.withFamily(brand),
headlineMedium = base.headlineMedium.withFamily(brand),
headlineSmall = base.headlineSmall.withFamily(brand),
titleLarge = base.titleLarge.withFamily(plain),
titleMedium = base.titleMedium.withFamily(plain),
titleSmall = base.titleSmall.withFamily(plain),
bodyLarge = base.bodyLarge.withFamily(plain),
bodyMedium = base.bodyMedium.withFamily(plain),
bodySmall = base.bodySmall.withFamily(plain),
labelLarge = base.labelLarge.withFamily(plain),
labelMedium = base.labelMedium.withFamily(plain),
labelSmall = base.labelSmall.withFamily(plain),
)
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.Typography
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
@@ -16,18 +15,15 @@ import androidx.compose.ui.platform.LocalContext
* - System light/dark.
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
* derived from [CalendulaSeed].
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
* Material scale on the system typeface.
*
* The Settings screen overrides darkTheme, dynamicColor, and typography; the
* bare defaults (used by the crash screen) just follow the system.
* The Settings screen (later) can override useDynamicColor and themePreference,
* but the V1 foundation just follows the system.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
typography: Typography = CalendulaTypography,
content: @Composable () -> Unit,
) {
val colorScheme = when {
@@ -46,7 +42,7 @@ fun CalendulaTheme(
// the bouncy variant felt overdone in review (2026-06-11).
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = typography,
typography = CalendulaTypography,
motionScheme = MotionScheme.standard(),
content = content,
)

Binary file not shown.

View File

@@ -12,7 +12,7 @@
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- 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 zu Beginn nicht — und nichts verlässt je dein Gerät.</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_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>
@@ -387,38 +387,4 @@
<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 ({year})</string>
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({year})</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 &amp; 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 {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt).</string>
<string name="settings_special_dates_reminders">Erinnerungen</string>
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
<string name="settings_special_dates_show_year_hint">{year} in Titeln anzeigen, wenn es 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 und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
<string name="settings_calendar_reminders_managed_hint">In Besondere Kontakttage festgelegt</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>

View File

@@ -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 all it asks for up front — and nothing ever leaves your device.</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_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,8 +68,6 @@
<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>
@@ -276,15 +274,6 @@
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Choose file…</string>
<string name="settings_font_custom_selected">Custom font</string>
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_time_format">Time format</string>
@@ -362,35 +351,6 @@
<string name="settings_views_subtitle">Quick-switch button and menu order</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 &amp; 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 {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
<string name="settings_special_dates_reminders">Reminders</string>
<string name="settings_special_dates_show_year">Show year</string>
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it 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 and visibility in Calendars settings.</string>
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</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>
@@ -509,13 +469,4 @@
<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 ({year})</string>
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -83,43 +83,6 @@ class AllDayReminderEncodingTest {
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
}
@Test
fun `next yearly occurrence wraps to next year once this year's date has passed`() {
val today = LocalDate.of(2026, 8, 1)
assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14))
assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25))
}
@Test
fun `next yearly occurrence returns today when the date is today`() {
val today = LocalDate.of(2026, 3, 10)
assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today)
}
@Test
fun `next yearly occurrence skips non-leap years for a Feb-29 date`() {
assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1)))
.isEqualTo(LocalDate.of(2028, 2, 29))
}
@Test
fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() {
// A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then,
// UTC+1). Sampling the offset there skews a modern CEST occurrence by an
// hour; sampling at the upcoming occurrence fires at the intended time.
val anchor1972 = LocalDate.of(1972, 7, 15)
val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST
val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm)
val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm)
val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence)
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
val fireFromNext = actualFire(rawFromNext, nextOccurrence)
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
}
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An

View File

@@ -1,15 +1,12 @@
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
@@ -86,8 +83,6 @@ 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. */
@@ -144,80 +139,6 @@ 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
}
/** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
val appliedManagedReminders = mutableListOf<Triple<Long, Int, List<Int>>>()
override fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
writeError?.let { throw it }
appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
}
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) {

View File

@@ -1,226 +0,0 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.provider.CalendarContract
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SpecialDatesSyncEngineTest {
private class FakeContacts(
var permission: Boolean = true,
var dates: List<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 ({year})"
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()).isEqualTo(SpecialDatesSyncResult.Disabled)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(
dates = listOf(
birthday("a", "Jane", 5, 14, 1990),
birthday("b", "Bob", 12, 25, null),
),
)
val (engine, settings) = engineWith(tempDir, contacts, calendars)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Success)
// All three type calendars exist; the birthday one holds both contacts.
assertThat(calendars.createdManagedCalendars.map { it.type })
.containsExactlyElementsIn(SpecialDateType.entries)
assertThat(calendars.insertedManagedEvents).hasSize(2)
assertThat(calendars.insertedManagedEvents.map { it.second })
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
// Year-less contact drops the year; year-known keeps it.
val titles = calendars.insertedManagedEvents.map { it.first.title }
assertThat(titles).contains("Jane's birthday (1990)")
assertThat(titles).contains("Bob's birthday")
// Calendar ids were persisted for the editor / next sync.
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
}
@Test
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
val insertsAfterFirst = calendars.insertedManagedEvents.size
val calendarsAfterFirst = calendars.createdManagedCalendars.size
engine.sync()
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
assertThat(calendars.updatedManagedFields).isEmpty()
assertThat(calendars.deletedEventIds).isEmpty()
}
@Test
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
engine.sync()
assertThat(calendars.updatedManagedFields).hasSize(1)
val (_, columns) = calendars.updatedManagedFields.single()
// Only managed columns are ever written — never reminders/location.
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)")
}
@Test
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
contacts.dates = emptyList()
engine.sync()
assertThat(calendars.deletedEventIds).hasSize(1)
}
@Test
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync()
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
engine.sync()
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
}
@Test
fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, settings) = engineWith(tempDir, contacts, calendars)
engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
// Persisted as the per-calendar all-day override (so new events match)...
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
.isEqualTo(listOf(0, 1440))
// ...and applied to the existing events in that calendar.
assertThat(calendars.appliedManagedReminders).contains(
Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)),
)
}
@Test
fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
}
// --- pure diff ---
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventDesired(uid, title, dt, rrule)
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventRow(id, uid, title, dt, rrule)
@Test
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
val diff = diffManagedEvents(
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
)
assertThat(diff.insertUids).containsExactly("new")
assertThat(diff.deleteEventIds).containsExactly(2L)
assertThat(diff.updates).isEmpty()
}
@Test
fun `diff emits only the changed managed columns`() {
val diff = diffManagedEvents(
desired = listOf(desired("a", "Janet", 150)),
existing = listOf(row(9, "a", "Jane", 100)),
)
val update = diff.updates.single()
assertThat(update.eventId).isEqualTo(9L)
assertThat(update.columns.keys)
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
}
}

View File

@@ -5,8 +5,6 @@ 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.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -200,22 +198,6 @@ class SettingsPrefsTest {
)
}
@Test
fun `updateQuickSwitch transforms the stored value, so intents compose`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
// A toggle then a reorder, each expressed against the *stored* config —
// the reorder must not resurrect the just-disabled view (the stale-UI-
// snapshot race this API exists to prevent).
prefs.updateQuickSwitch { it.copy(enabled = it.enabled - CalendarView.Day) }
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
prefs.updateQuickSwitch { it.copy(order = newOrder) }
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
assertThat(config.enabled).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
)
}
@Test
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -337,26 +319,6 @@ class SettingsPrefsTest {
}
}
@Test
fun `garbage per-calendar override values drop the entry, the none sentinel stays explicit`(
@TempDir tempDir: Path,
) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// Only "none" deliberately means an explicit no-reminder override; a
// value with no valid minutes is garbage and must drop its entry so the
// calendar inherits the global default instead of silently losing
// reminders. Partially-valid values keep their salvageable minutes.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.CALENDAR_REMINDER_OVERRIDE_KEY] = "5=none;7=abc;8=;9=30,abc;11=15"
m
}
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(
5L, emptyList<Int>(), 9L, listOf(30), 11L, listOf(15),
)
}
@Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -440,19 +402,6 @@ class SettingsPrefsTest {
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
}
@Test
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
// Existing installs have no stamp key — that must read as 0.
assertThat(prefs.brandFontStamp.first()).isEqualTo(0)
assertThat(prefs.plainFontStamp.first()).isEqualTo(0)
prefs.bumpCustomFontStamp(FontRole.BRAND)
prefs.bumpCustomFontStamp(FontRole.BRAND)
prefs.bumpCustomFontStamp(FontRole.PLAIN)
assertThat(prefs.brandFontStamp.first()).isEqualTo(2)
assertThat(prefs.plainFontStamp.first()).isEqualTo(1)
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
@@ -466,59 +415,4 @@ class SettingsPrefsTest {
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
}
@Test
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesEnabled.first()).isFalse()
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
assertThat(prefs.managedCalendarIds.first()).isEmpty()
assertThat(prefs.specialDatesShowYear.first()).isTrue()
}
@Test
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
assertThat(prefs.specialDatesTypes.first())
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
}
@Test
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
}
@Test
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
.isEqualTo("{name}'s birthday")
}
@Test
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
prefs.recordSpecialDatesRun(2000L, null)
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
}
}

View File

@@ -52,20 +52,4 @@ class PostableAlertsTest {
assertThat(postable).isEqualTo(due)
}
@Test
fun `alert with unknown calendar id 0 is never treated as disabled`() {
// Pre-upgrade snooze PendingIntents carry no calendar id (defaults to 0L).
val preUpgrade = alert(1, calendarId = 0L)
assertThat(preUpgrade.isForDisabledCalendar(disabledCalendarIds = setOf(0L))).isFalse()
assertThat(postableAlerts(listOf(preUpgrade), disabledCalendarIds = setOf(0L)))
.containsExactly(preUpgrade)
}
@Test
fun `isForDisabledCalendar matches only the disabled ids`() {
assertThat(alert(1, calendarId = 200).isForDisabledCalendar(setOf(200))).isTrue()
assertThat(alert(1, calendarId = 100).isForDisabledCalendar(setOf(200))).isFalse()
}
}

View File

@@ -1,129 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
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 kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SuppressedReminderStoreTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
)
private fun alert(
alertId: Long,
calendarId: Long,
endMillis: Long = Long.MAX_VALUE,
title: String = "Event $alertId",
location: String? = null,
) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = endMillis,
title = title,
location = location,
isAllDay = false,
)
@Test
fun `encode then decode round-trips every field including delimiters`() {
val original = alert(
alertId = 7,
calendarId = 42,
endMillis = 123_456_789L,
// Free-text with the field separator and other awkward characters.
title = "Lunch | with | Alice",
location = "Café, 3rd floor | room B",
).copy(beginMillis = 100L, isAllDay = true)
val decoded = decodeStashEntry(encodeStashEntry(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `decode returns null for a malformed entry`() {
assertThat(decodeStashEntry("not-a-valid-entry")).isNull()
}
@Test
fun `null location round-trips`() {
val original = alert(1, calendarId = 1, location = null)
assertThat(decodeStashEntry(encodeStashEntry(original))).isEqualTo(original)
}
@Test
fun `recoverFor returns and removes only the re-enabled calendars`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val keep = alert(1, calendarId = 100)
val recoverA = alert(2, calendarId = 200)
val recoverB = alert(3, calendarId = 200)
store.stash(listOf(keep, recoverA, recoverB), nowMillis = 0L)
val recovered = store.recoverFor(setOf(200L), nowMillis = 0L)
assertThat(recovered).containsExactly(recoverA, recoverB)
// The still-disabled calendar's alert stays stashed; the recovered ones are gone.
assertThat(store.recoverFor(setOf(100L, 200L), nowMillis = 0L)).containsExactly(keep)
}
@Test
fun `stash drops alerts whose event already ended`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val past = alert(1, calendarId = 100, endMillis = 500L)
val future = alert(2, calendarId = 100, endMillis = 2_000L)
store.stash(listOf(past, future), nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 1_000L)).containsExactly(future)
}
@Test
fun `purgeExpired removes only entries past their event end`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
// Stash both before "now" so neither is dropped on write, then advance time.
store.stash(
listOf(
alert(1, calendarId = 100, endMillis = 500L),
alert(2, calendarId = 100, endMillis = 2_000L),
),
nowMillis = 0L,
)
store.purgeExpired(nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 0L).map { it.alertId })
.containsExactly(2L)
}
@Test
fun `stash replaces an existing entry with the same alert id`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
store.stash(listOf(alert(1, calendarId = 100, title = "old")), nowMillis = 0L)
store.stash(listOf(alert(1, calendarId = 100, title = "new")), nowMillis = 0L)
val recovered = store.recoverFor(setOf(100L), nowMillis = 0L)
assertThat(recovered).hasSize(1)
assertThat(recovered.single().title).isEqualTo("new")
}
@Test
fun `isRelevantAt is true up to the event end and false after`() {
val a = alert(1, calendarId = 1, endMillis = 1_000L)
assertThat(a.isRelevantAt(999L)).isTrue()
assertThat(a.isRelevantAt(1_000L)).isTrue()
assertThat(a.isRelevantAt(1_001L)).isFalse()
}
@TempDir
lateinit var tempDir: Path
}

View File

@@ -0,0 +1,214 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventColorPaletteTest {
@Test
fun `empty palette stays empty`() {
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
}
@Test
fun `exact duplicate values collapse to the alphabetically first key`() {
val curated = listOf(
EventColorOption("cyan", 0xFF00FFFF.toInt()),
EventColorOption("aqua", 0xFF00FFFF.toInt()),
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker()
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
}
@Test
fun `small palettes pass through whole, so Google's curated set is untouched`() {
// A Google-like palette: two dozen distinct hand-picked colours.
val palette = (0 until 24).map {
val hue = it * 15
EventColorOption("$it", hsvArgb(hue.toFloat()))
}
val curated = palette.curatedForPicker()
assertThat(curated).containsExactlyElementsIn(palette)
}
@Test
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
val curated = css3Palette().curatedForPicker()
// The whole point of #22: ~147 published colours become a single
// manageable grid instead of a full screen.
assertThat(curated.size).isAtLeast(30)
assertThat(curated.size).isAtMost(60)
}
@Test
fun `curation never invents colours or drops keys`() {
val source = css3Palette()
val curated = source.curatedForPicker()
assertThat(source).containsAtLeastElementsIn(curated)
assertThat(curated.map { it.argb }).containsNoDuplicates()
}
@Test
fun `spelling-alias pairs never both survive`() {
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
val aliasPairs = listOf(
"aqua" to "cyan",
"fuchsia" to "magenta",
"gray" to "grey",
"darkgray" to "darkgrey",
"dimgray" to "dimgrey",
"lightgray" to "lightgrey",
"slategray" to "slategrey",
"lightslategray" to "lightslategrey",
"darkslategray" to "darkslategrey",
)
aliasPairs.forEach { (a, b) ->
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
}
}
@Test
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
// Black and every gray paint as the same pale swatch (the picker pins
// lightness and floors saturation), so only one survives — no stranded
// run of look-alike "pinks" at the end of the grid (#22).
val curated = listOf(
EventColorOption("black", 0xFF000000.toInt()),
EventColorOption("gray", 0xFF808080.toInt()),
EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
EventColorOption("blue", 0xFF0000FF.toInt()),
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker().map { it.key }
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
assertThat(curated).containsAtLeast("black", "red", "blue")
}
@Test
fun `a dark and a light shade of one hue collapse to a single swatch`() {
// The picker paints every swatch at one fixed lightness, so navy and a
// mid blue are indistinguishable once painted — keep just one.
val curated = listOf(
EventColorOption("navy", 0xFF000080.toInt()),
EventColorOption("blue", 0xFF0000FF.toInt()),
).curatedForPicker()
assertThat(curated).hasSize(1)
}
@Test
fun `the wheel is cut once, keeping each hue family contiguous`() {
// Twelve pure hues, deliberately shuffled; a small palette passes the
// thinning stage untouched so only the ordering is under test.
val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270)
val curated = shuffledHues
.map { EventColorOption("$it", hsvArgb(it.toFloat())) }
.curatedForPicker()
.map { it.key.toInt() }
// A proper single-seam sweep around the wheel descends exactly once
// (at the seam). The old bucketed sort could scatter a family across
// both ends, producing extra descents.
val descents = curated.indices.count { i ->
curated[(i + 1) % curated.size] < curated[i]
}
assertThat(descents).isEqualTo(1)
}
@Test
fun `CSS3 survivors span the whole rainbow`() {
val keys = css3Palette().curatedForPicker().map { it.key }
fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } }
// Which exact name represents a hue family depends on the vivid-first
// thinning, so assert each family survives, not a specific key.
assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue()
assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue()
assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue()
assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue()
assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue()
}
private fun hsvArgb(hue: Float): Int {
val h = hue / 60f
val sector = h.toInt() % 6
val f = h - h.toInt()
val q = ((1 - f) * 255).toInt()
val t = (f * 255).toInt()
return when (sector) {
0 -> argb(255, t, 0)
1 -> argb(q, 255, 0)
2 -> argb(0, 255, t)
3 -> argb(0, q, 255)
4 -> argb(t, 0, 255)
else -> argb(255, 0, q)
}
}
private fun argb(r: Int, g: Int, b: Int): Int =
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
EventColorOption(name, 0xFF000000.toInt() or rgb)
}
private val CSS3 = mapOf(
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
"yellowgreen" to 0x9ACD32,
)
}

View File

@@ -1,64 +0,0 @@
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()
}
}

View File

@@ -1,78 +0,0 @@
package de.jeanlucmakiola.calendula.domain.contacts
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class SpecialDateFormattingTest {
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
lookupKey = "k",
displayName = "Jane",
type = SpecialDateType.Birthday,
month = month,
day = day,
year = year,
)
@Test
fun `uid is deterministic and namespaced by type`() {
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
.isEqualTo("contact-birthday:abc@calendula")
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
.isEqualTo("contact-anniversary:abc@calendula")
}
private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate(
lookupKey = "k",
displayName = "Jane",
type = SpecialDateType.Custom,
month = month,
day = day,
year = null,
label = label,
)
@Test
fun `birthday and anniversary uids ignore the discriminator`() {
assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula")
}
@Test
fun `two custom dates on one contact get distinct uids by label`() {
assertThat(custom("Wedding", 6, 10).managedUid())
.isEqualTo("contact-custom:k:wedding@calendula")
assertThat(custom("Graduation", 9, 1).managedUid())
.isNotEqualTo(custom("Wedding", 6, 10).managedUid())
}
@Test
fun `label-less custom dates fall back to month-day so distinct dates survive`() {
assertThat(custom(null, 6, 10).managedUid())
.isEqualTo("contact-custom:k:06-10@calendula")
assertThat(custom(null, 9, 1).managedUid())
.isNotEqualTo(custom(null, 6, 10).managedUid())
}
@Test
fun `anchor date uses the known year`() {
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
}
@Test
fun `anchor date falls back to a leap year when year-less`() {
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
}
@Test
fun `title substitutes name and year`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
.isEqualTo("Jane's birthday (1990)")
}
@Test
fun `title drops an empty year parenthesis and tidies spacing`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
.isEqualTo("Jane's birthday")
}
}

View File

@@ -1,35 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import org.junit.jupiter.api.Test
/**
* The pure emit rule behind [ReminderDefaultPicker]: a non-empty selection is
* [CalendarReminderOverride.Minutes] (normalised), while clearing the last time
* reverts to [CalendarReminderOverride.Inherit] on a per-calendar picker (so an
* accidental toggle-undo can't silently wipe the calendar's default) and to
* explicit [CalendarReminderOverride.None] on the global default.
*/
class ReminderOverrideForMinutesTest {
@Test
fun `empty selection on a per-calendar picker reverts to inherit`() {
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
.isEqualTo(CalendarReminderOverride.Inherit)
}
@Test
fun `empty selection on the global default is explicit none`() {
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
.isEqualTo(CalendarReminderOverride.None)
}
@Test
fun `a non-empty selection is normalised minutes regardless of scope`() {
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = true))
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = false))
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
}
}

View File

@@ -1,61 +0,0 @@
package de.jeanlucmakiola.calendula.ui.theme
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class FontsTest {
@Test
fun `fromToken round-trips every bundled font`() {
BundledFont.entries.forEach { font ->
assertThat(BundledFont.fromToken(font.token)).isEqualTo(font)
}
}
@Test
fun `fromToken is null for system, custom, and unknown tokens`() {
assertThat(BundledFont.fromToken(FONT_SYSTEM_TOKEN)).isNull()
assertThat(BundledFont.fromToken(FONT_CUSTOM_TOKEN)).isNull()
assertThat(BundledFont.fromToken("not-a-font")).isNull()
assertThat(BundledFont.fromToken(null)).isNull()
}
@Test
fun `a bumped custom-font stamp breaks font-settings equality`() {
// Re-importing keeps the same "custom" token, so only the stamp can make
// the settings re-emit and invalidate remember() keys downstream.
val before = AppFontSettings(brand = FONT_CUSTOM_TOKEN, brandStamp = 1)
assertThat(before.copy(brandStamp = 2)).isNotEqualTo(before)
assertThat(before.copy(plainStamp = 1)).isNotEqualTo(before)
}
@Test
fun `system for both roles returns the untouched default typography`() {
assertThat(calendulaTypography(brand = null, plain = null))
.isSameInstanceAs(CalendulaTypography)
}
@Test
fun `brand drives display and headline, plain drives title, body, and label`() {
val brand = BundledFont.Lora.family
val plain = BundledFont.JetBrainsMono.family
val typography = calendulaTypography(brand = brand, plain = plain)
assertThat(typography.displayLarge.fontFamily).isEqualTo(brand)
assertThat(typography.headlineSmall.fontFamily).isEqualTo(brand)
assertThat(typography.titleLarge.fontFamily).isEqualTo(plain)
assertThat(typography.bodyMedium.fontFamily).isEqualTo(plain)
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
}
@Test
fun `a null role keeps that role's default family while the other is applied`() {
val plain = BundledFont.Lora.family
val typography = calendulaTypography(brand = null, plain = plain)
// Brand styles keep the Material default; plain styles switch.
assertThat(typography.displayLarge.fontFamily)
.isEqualTo(CalendulaTypography.displayLarge.fontFamily)
assertThat(typography.bodyLarge.fontFamily).isEqualTo(plain)
}
}

View File

@@ -1,248 +0,0 @@
# 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).
- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
age is impossible without per-year exception rows (rejected as too heavy). The
template instead offers `{year}` — the birth year (or an anniversary's start
year) — which is static and correct on every occurrence. A "Show year" toggle
gates it; it's in the default templates ("{name}'s birthday ({year})").
- New managed calendars seed a per-calendar all-day reminder default of
on-the-day + one week before, so birthdays get lead time out of the box.
- Title template is user-editable per type from day one (`{name}`, `{year}`).
This document captures the full design for surfacing contact birthdays —
and, by extension, anniversaries and custom dates — as auto-updating local
calendars in Calendula. It also specifies #14 (per-calendar *multiple*
default reminders), which is a hard prerequisite and ships first.
The two pieces are deliberately split across **two branches / two
shipments**:
- `feat/per-calendar-multi-reminders`#14, ships first.
- `feat/contact-special-dates`#15, builds on #14.
They *may* release together depending on timing, but #14 is independently
useful and is the foundation the special-dates calendars lean on.
---
## Why this is worth doing carefully
Calendula's identity is privacy: **no INTERNET permission**, and historically
**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
never holds the contacts permission (`EventEditScreen.kt:446-460`).
This feature **declares `READ_CONTACTS`** for the first time. That is a
deliberate, owner-approved shift, mitigated by:
- The permission is **opt-in and feature-gated** — requested only when the user
enables the feature, never at startup.
- Everything stays **offline**. Contacts are read locally and mirrored into a
local calendar; nothing leaves the device.
- The mirror is **one-way** (contacts → calendar). Calendula never writes to
Contacts.
Store listing / About copy will need to explain the optional permission.
---
## Prerequisite — #14: per-calendar *multiple* default reminders
### Today
- Events already support **multiple reminders** — `EventForm.reminders:
List<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 + `{year}` in titles (static, always correct).
- [x] Auto-update: on enable, on foreground, daily WorkManager.
- [x] Editor: disable managed fields for managed-calendar events.
- [x] Revoked-permission detection + stalled-state surface.
- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
- [x] Translatable strings (titles, sub-page).
- [x] About / store copy for the optional `READ_CONTACTS` permission.

View File

@@ -10,12 +10,6 @@ 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.

View File

@@ -8,11 +8,6 @@ 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.

View File

@@ -1,92 +0,0 @@
Copyright 2020 Braille Institute of America, Inc.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,17 +0,0 @@
# Bundled fonts
Calendula bundles the following fonts as optional user-selectable typefaces
(Settings → Appearance → Fonts). Each is licensed under the SIL Open Font
License 1.1; the full license text is included alongside this file.
| Font | Files (`app/src/main/res/font/`) | Copyright | License |
|------|----------------------------------|-----------|---------|
| Atkinson Hyperlegible | `atkinson_hyperlegible_regular.ttf`, `atkinson_hyperlegible_bold.ttf` | © 2020 Braille Institute of America, Inc. | [OFL](AtkinsonHyperlegible-OFL.txt) |
| Lora (variable) | `lora.ttf` | © 2011 Cyreal | [OFL](Lora-OFL.txt) |
| JetBrains Mono | `jetbrains_mono_regular.ttf`, `jetbrains_mono_bold.ttf` | © 2020 The JetBrains Mono Project Authors | [OFL](JetBrainsMono-OFL.txt) |
The device's default typeface (Roboto on most Android builds) remains the
default and is not bundled.
Fonts a user loads from their own device via "Choose file…" are copied into the
app's private storage and are not redistributed.