Compare commits
28 Commits
chore/fdro
...
030c3e6ce1
| Author | SHA1 | Date | |
|---|---|---|---|
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
| 225f4c3491 | |||
| 5d887524b1 | |||
| 048f407ba1 | |||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| 25ff7ce7ca | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d |
60
CHANGELOG.md
60
CHANGELOG.md
@@ -5,6 +5,60 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||
birthdays, anniversaries and other dates into local "Birthdays",
|
||||
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||
reminders the usual way — new birthdays even start with a reminder a week before
|
||||
*and* on the day. The title format is yours to customise (`{name}`, `{age}`).
|
||||
This is the first feature to use the contacts permission: it is requested only
|
||||
when you turn the feature on, everything stays on your device (Calendula has no
|
||||
internet access), and your contacts are only ever read, never changed. Thanks
|
||||
to @moonj for the suggestion ([#15]).
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
|
||||
### Fixed
|
||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
everywhere else in the app ([#17]).
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
## [2.11.2] — 2026-06-28
|
||||
|
||||
### Added
|
||||
@@ -706,3 +760,9 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||
[#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
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21102
|
||||
versionName = "2.11.2"
|
||||
versionCode = 21200
|
||||
versionName = "2.12.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -90,6 +90,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||
|
||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFB23B00</color>
|
||||
</resources>
|
||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||
build type only (release/releaseTest keep the production values), so the
|
||||
debug app is unmistakable on the launcher: its own label, alongside the
|
||||
real app thanks to the `.debug` applicationId suffix.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Calendula Debug</string>
|
||||
</resources>
|
||||
@@ -5,6 +5,15 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!--
|
||||
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||
contacts, and only after the user enables it and grants this at runtime
|
||||
(never requested at startup). Everything stays offline — birthdays and
|
||||
other contact dates are mirrored one-way into local calendars; contacts
|
||||
are never written and nothing leaves the device (the app has no INTERNET
|
||||
permission). See docs/design/contact-special-dates.md.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<!--
|
||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||
|
||||
@@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -25,6 +27,7 @@ class CalendulaApp : Application() {
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,4 +47,21 @@ class CalendulaApp : Application() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||
* orphaned work after the feature is turned off and re-schedules after a
|
||||
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||
* refresh, so no immediate run is needed here.
|
||||
*/
|
||||
private fun reconcileSpecialDates() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
SpecialDatesScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -29,6 +30,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
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
|
||||
@@ -101,19 +103,24 @@ class MainActivity : AppCompatActivity() {
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
// mistaken for the production app; compiled out of release.
|
||||
if (BuildConfig.DEBUG) DebugRibbon()
|
||||
}
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
|
||||
@@ -24,6 +24,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
@@ -97,6 +98,41 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||
*/
|
||||
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||
|
||||
/**
|
||||
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||
* detect when the user deleted one in system settings.
|
||||
*/
|
||||
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||
|
||||
/**
|
||||
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||
*/
|
||||
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||
|
||||
/**
|
||||
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||
|
||||
/**
|
||||
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||
*/
|
||||
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||
|
||||
/**
|
||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||
@@ -292,6 +328,134 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||
}
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.NAME, name)
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||
put(
|
||||
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||
)
|
||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
// The marker that identifies this as our special-dates calendar,
|
||||
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||
// holds the description, so the marker lives in CAL_SYNC2.
|
||||
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||
}
|
||||
val uri = resolver.insert(localCalendarsUri(), values)
|
||||
?: throw WriteFailedException("create managed calendar")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
|
||||
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
|
||||
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
|
||||
"$MANAGED_MARKER_COLUMN IS NOT NULL",
|
||||
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val type = typeForMarker(c.getString(1)) ?: continue
|
||||
add(ManagedCalendarRow(id = c.getLong(0), type = type))
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.RRULE,
|
||||
),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
add(
|
||||
ManagedEventRow(
|
||||
eventId = c.getLong(0),
|
||||
uid = uid,
|
||||
title = c.getString(2).orEmpty(),
|
||||
dtStartMillis = c.getLong(3),
|
||||
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||
val values = ContentValues().apply {
|
||||
put(
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||
)
|
||||
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
if (form.rrule == null) {
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, form.rrule)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
}
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
form.location.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
form.description.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?.let(ContentUris::parseId)
|
||||
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
|
||||
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||
// birthday reminder keeps their change.
|
||||
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to managed event $eventId")
|
||||
}
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||
if (values.isEmpty()) return
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(),
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||
}
|
||||
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
@@ -1038,6 +1202,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||
}
|
||||
|
||||
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
|
||||
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
|
||||
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
|
||||
}
|
||||
|
||||
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
|
||||
private fun typeForMarker(marker: String?): SpecialDateType? =
|
||||
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarDataSource"
|
||||
|
||||
@@ -1047,6 +1222,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
*/
|
||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||
|
||||
/**
|
||||
* Column and namespace for the special-dates marker. CAL_SYNC1 already
|
||||
* holds the local-calendar description, so the marker uses CAL_SYNC2.
|
||||
*/
|
||||
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||
|
||||
/**
|
||||
* How far ahead/behind a search looks for a recurring event's nearest
|
||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||
|
||||
@@ -118,8 +118,14 @@ internal fun buildEventUpdateValues(
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
* clone starts from the parent's values).
|
||||
*
|
||||
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
|
||||
/**
|
||||
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||
*/
|
||||
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||
|
||||
/**
|
||||
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||
* the desired state to decide whether a targeted update is needed.
|
||||
*/
|
||||
data class ManagedEventRow(
|
||||
val eventId: Long,
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Resolves the localized calendar names/templates and per-type colours the
|
||||
* mirror creates its calendars with. The colours are picked from the shared
|
||||
* palette so managed calendars look native alongside user calendars.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SpecialDatesCalendarSpec {
|
||||
|
||||
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun color(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple
|
||||
SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red
|
||||
SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||
* device's contacts. Read-only and offline — the one-way source for the
|
||||
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||
* the permission is absent so the sync can degrade to a stalled state rather
|
||||
* than crash.
|
||||
*/
|
||||
interface ContactSpecialDatesDataSource {
|
||||
fun hasPermission(): Boolean
|
||||
|
||||
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||
fun readSpecialDates(): List<ContactSpecialDate>
|
||||
}
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||
else -> SpecialDateType.Custom
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ContactSpecialDatesDataSource {
|
||||
|
||||
override fun hasPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||
if (!hasPermission()) return emptyList()
|
||||
val resolver = context.contentResolver
|
||||
// A contact can carry the same date more than once (multiple raw
|
||||
// contacts under one aggregate); keep the first per (contact, type).
|
||||
val seen = HashSet<Pair<String, SpecialDateType>>()
|
||||
val result = ArrayList<ContactSpecialDate>()
|
||||
runCatching {
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
PROJECTION,
|
||||
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||
while (c.moveToNext()) {
|
||||
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||
if (!seen.add(lookup to type)) continue
|
||||
result += ContactSpecialDate(
|
||||
lookupKey = lookup,
|
||||
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||
type = type,
|
||||
month = parts.month,
|
||||
day = parts.day,
|
||||
year = parts.year,
|
||||
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||
return result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ContactSpecialDates"
|
||||
|
||||
val PROJECTION = arrayOf(
|
||||
ContactsContract.Data.LOOKUP_KEY,
|
||||
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||
Event.START_DATE,
|
||||
Event.TYPE,
|
||||
Event.LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||
*/
|
||||
object SpecialDatesScheduler {
|
||||
|
||||
private const val WORK_NAME = "special-dates-sync"
|
||||
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||
private const val KEY_FOREGROUND = "foreground"
|
||||
|
||||
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||
fun apply(context: Context, enabled: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||
// Delay the first periodic run so it never overlaps an immediate run.
|
||||
.setInitialDelay(1, TimeUnit.DAYS)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one reconcile immediately. [foreground] runs are debounced (the app can
|
||||
* resume often); enable/"Sync now" runs ([foreground] false) always sync.
|
||||
*/
|
||||
fun runNow(context: Context, foreground: Boolean = false) {
|
||||
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default worker factory. Records
|
||||
* the run for the settings status line; a missing permission parks the feature
|
||||
* in a stalled state (surfaced in settings) rather than retrying forever.
|
||||
*/
|
||||
class SpecialDatesSyncWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun syncEngine(): SpecialDatesSyncEngine
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work; never revive a
|
||||
// disabled feature.
|
||||
if (!prefs.specialDatesEnabled.first()) return Result.success()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
// A foreground resume can fire often — skip if we synced recently.
|
||||
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
|
||||
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
return try {
|
||||
val result = deps.syncEngine().sync(today)
|
||||
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||
SpecialDatesStalledReason.PermissionRevoked
|
||||
} else {
|
||||
null
|
||||
}
|
||||
prefs.recordSpecialDatesRun(now, stalled)
|
||||
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Special-dates sync failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SpecialDatesSync"
|
||||
|
||||
/** Skip a foreground-triggered sync if one ran within this window (4h). */
|
||||
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ageAtNextOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||
|
||||
/**
|
||||
* Localized, per-type presentation the engine needs but can't derive purely
|
||||
* (calendar display name, colour, and the default title template). Kept behind
|
||||
* an interface so the engine's diff stays unit-testable without Android
|
||||
* resources.
|
||||
*/
|
||||
interface SpecialDatesCalendarSpec {
|
||||
fun displayName(type: SpecialDateType): String
|
||||
fun color(type: SpecialDateType): Int
|
||||
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||
*/
|
||||
@Singleton
|
||||
class SpecialDatesSyncEngine @Inject constructor(
|
||||
private val contacts: ContactSpecialDatesDataSource,
|
||||
private val calendars: CalendarDataSource,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val spec: SpecialDatesCalendarSpec,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Reconcile every enabled type against the device's contacts. [today] anchors
|
||||
* the age snapshot. Returns why it stopped early (disabled / permission gone)
|
||||
* or [SpecialDatesSyncResult.Success].
|
||||
*/
|
||||
suspend fun sync(today: LocalDate): SpecialDatesSyncResult {
|
||||
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||
|
||||
val enabledTypes = prefs.specialDatesTypes.first()
|
||||
val calendarByType = reconcileCalendars(enabledTypes)
|
||||
|
||||
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||
val reminderCtx = readReminderContext()
|
||||
val templates = prefs.specialDatesTitleTemplates.first()
|
||||
val showAge = prefs.specialDatesShowAge.first()
|
||||
|
||||
calendarByType.forEach { (type, calendarId) ->
|
||||
val template = templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: spec.defaultTitleTemplate(type)
|
||||
syncType(
|
||||
calendarId = calendarId,
|
||||
type = type,
|
||||
contactsOfType = desiredByType[type].orEmpty(),
|
||||
today = today,
|
||||
template = template,
|
||||
showAge = showAge,
|
||||
reminderCtx = reminderCtx,
|
||||
)
|
||||
}
|
||||
return SpecialDatesSyncResult.Success
|
||||
}
|
||||
|
||||
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||
suspend fun teardown() {
|
||||
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||
* existing one, or the stored id if it still exists, else creating one) and
|
||||
* that disabled types have none. Returns the calendar id per enabled type.
|
||||
*/
|
||||
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||
val foundByType = calendars.findManagedCalendars()
|
||||
.groupBy({ it.type }, { it.id })
|
||||
.mapValues { it.value.first() }
|
||||
val stored = prefs.specialDatesCalendars.first()
|
||||
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||
for (type in SpecialDateType.entries) {
|
||||
// A stored id counts only if the calendar still exists (the user may
|
||||
// have deleted it in system settings); otherwise adopt a found one.
|
||||
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||
?: foundByType[type]
|
||||
if (type in enabledTypes) {
|
||||
val id = existingId ?: createCalendar(type)
|
||||
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||
result[type] = id
|
||||
} else {
|
||||
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||
prefs.setSpecialDatesCalendarId(type, id)
|
||||
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||
// only set when the user hasn't already configured this calendar.
|
||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||
prefs.setCalendarAllDayReminderOverride(
|
||||
id,
|
||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private suspend fun syncType(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
contactsOfType: List<ContactSpecialDate>,
|
||||
today: LocalDate,
|
||||
template: String,
|
||||
showAge: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
) {
|
||||
val built = contactsOfType.map { sd ->
|
||||
buildManagedEvent(calendarId, type, sd, today, template, showAge, reminderCtx)
|
||||
}
|
||||
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||
val formByUid = built.associate { it.desired.uid to it.form }
|
||||
diff.insertUids.forEach { uid ->
|
||||
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||
}
|
||||
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||
}
|
||||
|
||||
private fun buildManagedEvent(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
sd: ContactSpecialDate,
|
||||
today: LocalDate,
|
||||
template: String,
|
||||
showAge: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
): BuiltManagedEvent {
|
||||
val anchor = sd.anchorDate()
|
||||
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||
val age = if (showAge) sd.ageAtNextOccurrence(today) else null
|
||||
val title = renderSpecialDateTitle(template, sd.displayName, age)
|
||||
val form = EventForm(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
isAllDay = true,
|
||||
start = start,
|
||||
end = start,
|
||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||
availability = Availability.Free,
|
||||
rrule = YEARLY_RRULE,
|
||||
)
|
||||
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||
return BuiltManagedEvent(
|
||||
desired = ManagedEventDesired(
|
||||
uid = managedEventUid(type, sd.lookupKey),
|
||||
title = title,
|
||||
dtStartMillis = dtStartMillis,
|
||||
rrule = YEARLY_RRULE,
|
||||
),
|
||||
form = form,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||
timedGlobal = prefs.defaultReminderMinutes.first(),
|
||||
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||
timedOverrides = prefs.perCalendarReminderOverride.first(),
|
||||
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
private data class ReminderContext(
|
||||
val timedGlobal: List<Int>,
|
||||
val allDayGlobal: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
val allDayTimeMinutes: Int,
|
||||
) {
|
||||
fun resolveAllDay(calendarId: Long): List<Int> = resolveDefaultReminder(
|
||||
timedGlobal = timedGlobal,
|
||||
allDayGlobal = allDayGlobal,
|
||||
timedOverrides = timedOverrides,
|
||||
allDayOverrides = allDayOverrides,
|
||||
calendarId = calendarId,
|
||||
isAllDay = true,
|
||||
)
|
||||
}
|
||||
|
||||
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||
|
||||
private companion object {
|
||||
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||
}
|
||||
}
|
||||
|
||||
/** The managed columns of a desired event, compared against the existing row. */
|
||||
internal data class ManagedEventDesired(
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
|
||||
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||
|
||||
internal data class ManagedDiff(
|
||||
val insertUids: List<String>,
|
||||
val updates: List<ManagedFieldUpdate>,
|
||||
val deleteEventIds: List<Long>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||
* so a re-run with no contact changes produces nothing, and a managed update
|
||||
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||
*/
|
||||
internal fun diffManagedEvents(
|
||||
desired: List<ManagedEventDesired>,
|
||||
existing: List<ManagedEventRow>,
|
||||
): ManagedDiff {
|
||||
val desiredByUid = desired.associateBy { it.uid }
|
||||
val existingByUid = existing.associateBy { it.uid }
|
||||
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||
val updates = buildList {
|
||||
for (d in desired) {
|
||||
val row = existingByUid[d.uid] ?: continue
|
||||
val columns = buildMap<String, Any?> {
|
||||
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||
if (row.dtStartMillis != d.dtStartMillis) {
|
||||
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||
}
|
||||
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||
}
|
||||
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||
}
|
||||
}
|
||||
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindContactSpecialDatesDataSource(
|
||||
impl: AndroidContactSpecialDatesDataSource,
|
||||
): ContactSpecialDatesDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSpecialDatesCalendarSpec(
|
||||
impl: AndroidSpecialDatesCalendarSpec,
|
||||
): SpecialDatesCalendarSpec
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
@@ -38,6 +39,19 @@ sealed interface WeekStartPref {
|
||||
*/
|
||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that have already finished today: [SHOW]
|
||||
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
|
||||
* them from the list entirely.
|
||||
*/
|
||||
enum class PastEventDisplay { SHOW, DIM, HIDE }
|
||||
|
||||
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
|
||||
fun parsePastEventDisplay(
|
||||
stored: String?,
|
||||
default: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
|
||||
|
||||
/**
|
||||
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||
* device's `DateFormat.is24HourFormat` value).
|
||||
@@ -116,6 +130,31 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that already ended today. Defaults to
|
||||
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
|
||||
* or hiding.
|
||||
*/
|
||||
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
|
||||
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
|
||||
}
|
||||
|
||||
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the month/week grids fade events that have already finished.
|
||||
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
|
||||
*/
|
||||
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setDimCompletedEvents(enabled: Boolean) {
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -238,35 +277,36 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time (minutes before start) prefilled on new
|
||||
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||
* as the factory default so existing users aren't surprised by reminders they
|
||||
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* The default reminder lead times (minutes before start) prefilled on new
|
||||
* **timed** events. The empty list = no default reminder — the prior
|
||||
* behaviour, kept as the factory default so existing users aren't surprised by
|
||||
* reminders they never asked for. Stored as a comma-joined list of minutes, or
|
||||
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||
* ("30") parses transparently to a one-element list. Per-calendar overrides in
|
||||
* [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||
*/
|
||||
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time prefilled on new **all-day** events, in
|
||||
* The default reminder lead times prefilled on new **all-day** events, in
|
||||
* minutes before the start of the day. All-day events want day-scale lead
|
||||
* times ("1 day before"), so they have their own default rather than reusing
|
||||
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||
* to all-day events — they always use this global value.
|
||||
* the timed one. Empty list = no default. Per-calendar overrides do **not**
|
||||
* apply to all-day events — they always use this global value.
|
||||
*/
|
||||
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,13 +343,13 @@ class SettingsPrefs @Inject constructor(
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||
* timed default for its new events: a `null` value means "no reminder", an int
|
||||
* means that lead time. A calendar **absent** from the map inherits the global
|
||||
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||
* no-reminder override. (All-day events ignore this and use
|
||||
* [defaultAllDayReminderMinutes].)
|
||||
* timed default for its new events: an **empty** list means "no reminder", a
|
||||
* non-empty list means those lead times. A calendar **absent** from the map
|
||||
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||
* is a comma-joined minute list or `none` for an explicit no-reminder override.
|
||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||
*/
|
||||
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -326,7 +366,7 @@ class SettingsPrefs @Inject constructor(
|
||||
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||
* inherit the global all-day default; present null = no reminder).
|
||||
*/
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -388,6 +428,95 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contact special dates (issue #15) ------------------------------
|
||||
|
||||
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
|
||||
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
|
||||
|
||||
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** The date types the user wants mirrored (default: all three). */
|
||||
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
|
||||
}
|
||||
|
||||
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||
store.edit { it[typeEnabledKey(type)] = enabled }
|
||||
}
|
||||
|
||||
/** The managed calendar id per type, for the ones that currently exist. */
|
||||
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.mapNotNull { type ->
|
||||
prefs[calendarIdKey(type)]?.let { type to it }
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
|
||||
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
|
||||
|
||||
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
|
||||
store.edit { prefs ->
|
||||
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The title template per type ("{name}'s birthday"); an empty string means
|
||||
* unset, so the settings screen can seed the localized default. `{name}` is
|
||||
* the contact name, `{age}` the age at the upcoming date (empty when the
|
||||
* birth year is unknown or [specialDatesShowAge] is off).
|
||||
*/
|
||||
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
|
||||
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
|
||||
}
|
||||
|
||||
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||
store.edit { it[titleTemplateKey(type)] = template }
|
||||
}
|
||||
|
||||
/** Whether `{age}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||
val specialDatesShowAge: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_AGE_KEY] ?: true }
|
||||
|
||||
suspend fun setSpecialDatesShowAge(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_SHOW_AGE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Outcome of the last mirror sync, for the settings status line. */
|
||||
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||
SpecialDatesStatus(
|
||||
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||
store.edit { prefs ->
|
||||
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||
}
|
||||
}
|
||||
|
||||
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||
val specialDatesLastForegroundSync: Flow<Long> =
|
||||
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||
|
||||
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||
}
|
||||
|
||||
private fun typeEnabledKey(type: SpecialDateType) =
|
||||
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||
|
||||
private fun calendarIdKey(type: SpecialDateType) =
|
||||
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -404,6 +533,8 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
@@ -438,9 +569,30 @@ class SettingsPrefs @Inject constructor(
|
||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||
const val MIN_BACKUP_INTERVAL = 30L
|
||||
|
||||
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
|
||||
internal val SPECIAL_DATES_SHOW_AGE_KEY = booleanPreferencesKey("special_dates_show_age")
|
||||
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||
longPreferencesKey("special_dates_last_foreground_sync")
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||
data class SpecialDatesStatus(
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long,
|
||||
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||
val stalled: SpecialDatesStalledReason?,
|
||||
)
|
||||
|
||||
/** Why the special-dates mirror is paused. */
|
||||
enum class SpecialDatesStalledReason {
|
||||
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||
PermissionRevoked,
|
||||
}
|
||||
|
||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||
data class BackupStatus(
|
||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||
@@ -455,43 +607,46 @@ sealed interface CalendarReminderOverride {
|
||||
data object Inherit : CalendarReminderOverride
|
||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||
data object None : CalendarReminderOverride
|
||||
/** A specific lead time in minutes before the event start. */
|
||||
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead time to prefill on a new event: the matching per-calendar override
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||
* it can be unit-tested.
|
||||
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||
* Pure so it can be unit-tested.
|
||||
*/
|
||||
fun resolveDefaultReminder(
|
||||
timedGlobal: Int?,
|
||||
allDayGlobal: Int?,
|
||||
timedOverrides: Map<Long, Int?>,
|
||||
allDayOverrides: Map<Long, Int?>,
|
||||
timedGlobal: List<Int>,
|
||||
allDayGlobal: List<Int>,
|
||||
timedOverrides: Map<Long, List<Int>>,
|
||||
allDayOverrides: Map<Long, List<Int>>,
|
||||
calendarId: Long?,
|
||||
isAllDay: Boolean,
|
||||
): Int? {
|
||||
): List<Int> {
|
||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||
overrides[calendarId]
|
||||
overrides.getValue(calendarId)
|
||||
} else {
|
||||
global
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||
private fun MutableMap<Long, Int?>.applyOverride(
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, null)
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,24 +672,36 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
||||
private const val NONE = "none"
|
||||
private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||
null, "", NONE -> null
|
||||
else -> toIntOrNull()
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||
* one-element list. Non-numeric parts are dropped defensively.
|
||||
*/
|
||||
private fun String?.toReminderList(): List<Int> = when {
|
||||
this == null || isEmpty() || this == NONE -> emptyList()
|
||||
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||
}
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
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
|
||||
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||
id to value
|
||||
id to parts[1].toReminderList()
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* 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.calendarId in disabledCalendarIds }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
due.forEach { notifier.post(it) }
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones.
|
||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
private const val EXTRA_END = "end"
|
||||
private const val EXTRA_TITLE = "title"
|
||||
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
ReminderAlert(
|
||||
alertId = c.getLong(0),
|
||||
eventId = c.getLong(1),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 1,
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
|
||||
@@ -40,6 +40,14 @@ data class EventInstance(
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||
* visibility and reminder defaults for free. See
|
||||
* docs/design/contact-special-dates.md.
|
||||
*/
|
||||
enum class SpecialDateType {
|
||||
Birthday,
|
||||
Anniversary,
|
||||
|
||||
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||
Custom,
|
||||
}
|
||||
|
||||
/**
|
||||
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||
* mirror without duplicating; [year] is null when the contact stored the date
|
||||
* without one (`--MM-dd`), in which case age can't be shown.
|
||||
*/
|
||||
data class ContactSpecialDate(
|
||||
val lookupKey: String,
|
||||
val displayName: String,
|
||||
val type: SpecialDateType,
|
||||
val month: Int,
|
||||
val day: Int,
|
||||
val year: Int?,
|
||||
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/** The parsed month/day (+ optional year) of a contact date. */
|
||||
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||
|
||||
/**
|
||||
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||
*/
|
||||
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||
|
||||
/**
|
||||
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||
* seen in the wild:
|
||||
* - full `yyyy-MM-dd` (year known),
|
||||
* - year-less `--MM-dd` (year null),
|
||||
* - compact `yyyyMMdd`.
|
||||
*
|
||||
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||
* it's unit-tested without a device.
|
||||
*/
|
||||
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||
val s = raw?.trim().orEmpty()
|
||||
if (s.isEmpty()) return null
|
||||
|
||||
// Year-less: "--MM-dd" or "--MMdd".
|
||||
if (s.startsWith("--")) {
|
||||
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||
return validated(null, m, d)
|
||||
}
|
||||
|
||||
if (s.contains('-')) {
|
||||
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||
return when (parts.size) {
|
||||
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||
// first part, so require the first token to look like a year.
|
||||
3 -> if (s.startsWith('-')) null else validated(
|
||||
year = parts[0].toIntOrNull() ?: return null,
|
||||
month = parts[1].toIntOrNull() ?: return null,
|
||||
day = parts[2].toIntOrNull() ?: return null,
|
||||
)
|
||||
// Year-less "MM-dd" without the "--" prefix.
|
||||
2 -> validated(
|
||||
year = null,
|
||||
month = parts[0].toIntOrNull() ?: return null,
|
||||
day = parts[1].toIntOrNull() ?: return null,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Compact "yyyyMMdd".
|
||||
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||
return validated(
|
||||
year = s.substring(0, 4).toInt(),
|
||||
month = s.substring(4, 6).toInt(),
|
||||
day = s.substring(6, 8).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** "MM-dd" or compact "MMdd". */
|
||||
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||
if (rest.contains('-')) {
|
||||
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||
if (p.size != 2) return null
|
||||
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||
}
|
||||
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm month/day form a real calendar date (validated against the actual
|
||||
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||
*/
|
||||
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||
if (month !in 1..12 || day !in 1..31) return null
|
||||
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||
.map { ContactDateParts(year, month, day) }
|
||||
.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.number
|
||||
|
||||
/**
|
||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||
* contact date. Stable across syncs (the reconciliation key), and namespaced by
|
||||
* type so the same contact's birthday and anniversary never collide.
|
||||
*/
|
||||
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
|
||||
"contact-${type.name.lowercase()}:$lookupKey@calendula"
|
||||
|
||||
/**
|
||||
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||
* date when the year is known (so age can be derived), otherwise the month/day
|
||||
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||
* anchor never drifts between syncs.
|
||||
*/
|
||||
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||
|
||||
/**
|
||||
* The age the contact reaches on the *upcoming* occurrence of this date,
|
||||
* relative to [today], or null when the year is unknown. Because a
|
||||
* `FREQ=YEARLY` event carries a single static title, this is a snapshot taken
|
||||
* at sync time (refreshed on the next sync), not a per-occurrence value.
|
||||
*/
|
||||
fun ContactSpecialDate.ageAtNextOccurrence(today: LocalDate): Int? {
|
||||
val birthYear = year ?: return null
|
||||
val monthDayPassedThisYear = month < today.month.number ||
|
||||
(month == today.month.number && day < today.day)
|
||||
val nextYear = if (monthDayPassedThisYear) today.year + 1 else today.year
|
||||
return nextYear - birthYear
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a title [template] for a contact, substituting `{name}` and `{age}`
|
||||
* (the latter empty when [age] is null). Collapses the whitespace the empty
|
||||
* `{age}` may leave behind, so "{name}'s birthday ({age})" degrades cleanly to
|
||||
* "Jane's birthday" when no year is known.
|
||||
*/
|
||||
fun renderSpecialDateTitle(template: String, name: String, age: Int?): String =
|
||||
template
|
||||
.replace("{name}", name)
|
||||
.replace("{age}", age?.toString().orEmpty())
|
||||
// Drop an empty "()" left by an unresolved {age}, then tidy spacing.
|
||||
.replace(Regex("""\(\s*\)"""), "")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
@@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
@@ -49,6 +50,15 @@ fun RootScreen(
|
||||
hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
// Refresh the contact special-dates mirror on foreground (the
|
||||
// worker is debounced and no-ops when the feature is off). Gated
|
||||
// on the permission so users without the opt-in never enqueue it.
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CONTACTS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -53,7 +54,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
@@ -61,12 +64,14 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
@@ -96,6 +101,7 @@ fun AgendaScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -178,6 +184,7 @@ fun AgendaScreen(
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
@@ -275,6 +282,7 @@ private fun AgendaRangeBanner(
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -284,19 +292,42 @@ private fun AgendaContent(
|
||||
is AgendaUiState.Failure -> Box(modifier) {
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
}
|
||||
is AgendaUiState.Success ->
|
||||
if (state.days.isEmpty()) {
|
||||
is AgendaUiState.Success -> {
|
||||
val now by rememberCurrentMinute()
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
state: AgendaUiState.Success,
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -305,9 +336,9 @@ private fun AgendaList(
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = state.today)
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
@@ -316,6 +347,7 @@ private fun AgendaList(
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
@@ -348,13 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
@@ -53,6 +54,18 @@ class AgendaViewModel @Inject constructor(
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
* concern only, so it rides alongside the data state rather than re-querying;
|
||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||
*/
|
||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fun InlineTextField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
@@ -43,15 +44,17 @@ fun InlineTextField(
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = if (textStyle.color.isSpecified) {
|
||||
textStyle.color
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
color = when {
|
||||
// A disabled field reads as dimmed, like a locked value.
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
textStyle.color.isSpecified -> textStyle.color
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
textStyle = resolvedStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
|
||||
@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
|
||||
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberCurrentMinute(): State<Instant> {
|
||||
fun rememberCurrentMinute(): State<Instant> {
|
||||
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The cut-off instant before which a calendar event counts as "completed" and is
|
||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -111,10 +113,15 @@ fun <T> OptionPicker(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||
* 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"). 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(
|
||||
@@ -125,57 +132,74 @@ fun ReminderDefaultPicker(
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||
// 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) }
|
||||
val inherits = current is CalendarReminderOverride.Inherit
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
// 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(seed.first) }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||
add(CalendarReminderOverride.None)
|
||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
// 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) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
// 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 = reminderOverrideLabel(option),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
onClick = { toggle(minute) },
|
||||
)
|
||||
}
|
||||
// When expanded, the Custom row connects downward into the editor card
|
||||
// so the two read as one grouped container (the per-calendar pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
stringResource(
|
||||
R.string.reminder_custom_with_value,
|
||||
reminderLeadTimeLabel(selectedMinutes!!),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.event_edit_reminder_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
@@ -189,8 +213,9 @@ fun ReminderDefaultPicker(
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||
onDismiss()
|
||||
emit(selectedMinutes + minutes)
|
||||
amountText = ""
|
||||
customExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -428,18 +453,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||
}
|
||||
|
||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||
minutes == null -> "" to ReminderUnit.Minutes
|
||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||
else -> minutes.toString() to ReminderUnit.Minutes
|
||||
}
|
||||
|
||||
@@ -435,6 +435,10 @@ private fun EventEditContent(
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
val dark = isSystemInDarkTheme()
|
||||
// The title, date and recurrence are managed by the special-dates sync, so
|
||||
// they're locked here; everything else (reminders, location, notes) is the
|
||||
// user's to edit.
|
||||
val locked = state.isManaged
|
||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -519,6 +523,7 @@ private fun EventEditContent(
|
||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium
|
||||
.copy(fontWeight = FontWeight.SemiBold),
|
||||
enabled = !locked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
@@ -531,6 +536,17 @@ private fun EventEditContent(
|
||||
.height(3.dp)
|
||||
.background(accent, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
if (locked) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_managed_hint,
|
||||
selectedCalendar?.displayName.orEmpty(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
@@ -549,7 +565,11 @@ private fun EventEditContent(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
||||
Switch(
|
||||
checked = form.isAllDay,
|
||||
onCheckedChange = viewModel::setAllDay,
|
||||
enabled = !locked,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -561,6 +581,7 @@ private fun EventEditContent(
|
||||
locale = locale,
|
||||
onDateClick = { picker = PickerTarget.StartDate },
|
||||
onTimeClick = { picker = PickerTarget.StartTime },
|
||||
enabled = !locked,
|
||||
)
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.event_edit_ends),
|
||||
@@ -570,6 +591,7 @@ private fun EventEditContent(
|
||||
onDateClick = { picker = PickerTarget.EndDate },
|
||||
onTimeClick = { picker = PickerTarget.EndTime },
|
||||
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||
enabled = !locked,
|
||||
)
|
||||
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -700,7 +722,7 @@ private fun EventEditContent(
|
||||
EditCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = null,
|
||||
onClick = { showRecurrencePicker = true },
|
||||
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1858,6 +1880,7 @@ private fun InlineField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1871,6 +1894,7 @@ private fun InlineField(
|
||||
textStyle = textStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
)
|
||||
}
|
||||
@@ -1885,6 +1909,7 @@ private fun ScheduleRow(
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit,
|
||||
isError: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val dateFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -1894,11 +1919,12 @@ private fun ScheduleRow(
|
||||
timeOfDayFormatter(use24Hour, locale)
|
||||
}
|
||||
// Tappable values read as links (primary), like the location on the
|
||||
// detail screen; errors flip them to the error colour.
|
||||
val valueColor = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
// detail screen; errors flip them to the error colour; a locked (managed)
|
||||
// row dims to a plain, non-tappable value.
|
||||
val valueColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
isError -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1915,7 +1941,7 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onDateClick)
|
||||
.clickable(enabled = enabled, onClick = onDateClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
if (!isAllDay) {
|
||||
@@ -1924,7 +1950,7 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onTimeClick)
|
||||
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
* True when the event's calendar is a contact special-dates calendar the app
|
||||
* manages. While editing such an event the title, date and recurrence are
|
||||
* locked (sync overwrites them); reminders, location and notes stay editable.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/**
|
||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||
|
||||
@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val timed: Int?,
|
||||
val allDay: Int?,
|
||||
val timedOverrides: Map<Long, Int?>,
|
||||
val allDayOverrides: Map<Long, Int?>,
|
||||
val timed: List<Int>,
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
|
||||
private data class ExternalInputs(
|
||||
@@ -165,7 +165,8 @@ class EventEditViewModel @Inject constructor(
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
) { local, external, palette ->
|
||||
settingsPrefs.managedCalendarIds,
|
||||
) { local, external, palette, managedIds ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
@@ -180,6 +181,8 @@ class EventEditViewModel @Inject constructor(
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
// Editing an event in a managed calendar locks its synced fields.
|
||||
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
@@ -260,7 +263,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val default = resolveDefaultReminder(
|
||||
val reminders = resolveDefaultReminder(
|
||||
timedGlobal = defaults.timed,
|
||||
allDayGlobal = defaults.allDay,
|
||||
timedOverrides = defaults.timedOverrides,
|
||||
@@ -268,7 +271,6 @@ class EventEditViewModel @Inject constructor(
|
||||
calendarId = targetId,
|
||||
isAllDay = form.isAllDay,
|
||||
)
|
||||
val reminders = listOfNotNull(default)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
|
||||
@@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -60,10 +63,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -97,6 +104,14 @@ fun MonthScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -193,14 +208,16 @@ fun MonthScreen(
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,6 +575,8 @@ private fun MonthBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -565,7 +584,7 @@ private fun MonthBar(
|
||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
|
||||
@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
@@ -86,10 +87,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
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
|
||||
@@ -120,7 +123,7 @@ import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Calendar
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||
private enum class SettingsSection { Appearance, EventForm, Notifications, SpecialDates }
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||
@@ -179,6 +182,13 @@ fun SettingsScreen(
|
||||
) {
|
||||
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.SpecialDates,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +234,13 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||
onClick = onManageCalendars,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
summary = stringResource(R.string.settings_special_dates_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.SpecialDates) },
|
||||
)
|
||||
LanguageRow(position = Position.Middle)
|
||||
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||
// is API 33+; on older versions the tile is still addable manually from
|
||||
@@ -475,6 +492,7 @@ private fun AppearanceScreen(
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
@@ -533,7 +551,7 @@ private fun AppearanceScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
@@ -542,16 +560,36 @@ private fun AppearanceScreen(
|
||||
},
|
||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dim_completed),
|
||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dimCompletedEvents,
|
||||
onCheckedChange = viewModel::setDimCompletedEvents,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Agenda
|
||||
// Agenda — the only group labelled, so its agenda-specific rows are easy
|
||||
// to pick out among the otherwise unheadered Appearance settings.
|
||||
SectionHeader(stringResource(R.string.settings_agenda_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaScreenRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
||||
position = Position.Middle,
|
||||
onClick = { showPastEvents = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||
@@ -620,6 +658,16 @@ private fun AppearanceScreen(
|
||||
onDismiss = { showTimeFormat = false },
|
||||
)
|
||||
}
|
||||
if (showPastEvents) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
options = PastEventDisplay.entries,
|
||||
selected = state.pastEventDisplay,
|
||||
label = { pastEventDisplayLabel(it) },
|
||||
onSelect = viewModel::setPastEventDisplay,
|
||||
onDismiss = { showPastEvents = false },
|
||||
)
|
||||
}
|
||||
if (showDefaultView) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
@@ -937,7 +985,7 @@ private fun NotificationsScreen(
|
||||
presets = REMINDER_PRESETS,
|
||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showDefaultReminder = false },
|
||||
)
|
||||
}
|
||||
@@ -947,7 +995,7 @@ private fun NotificationsScreen(
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showAllDayReminder = false },
|
||||
)
|
||||
}
|
||||
@@ -993,16 +1041,267 @@ private fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contact special dates (issue #15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// READ_CONTACTS is requested only here, on enable — never at startup. On a
|
||||
// grant we either enable (if turning on) or just re-sync (clearing a stalled
|
||||
// banner after the permission was re-granted).
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
|
||||
}
|
||||
}
|
||||
val requestOrEnable: () -> Unit = {
|
||||
if (hasContactsPermission(context)) {
|
||||
viewModel.setSpecialDatesEnabled(true)
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
}
|
||||
|
||||
var confirmDisableAll by remember { mutableStateOf(false) }
|
||||
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
onBack = onBack,
|
||||
) {
|
||||
// Paused banner: the permission was revoked after enabling.
|
||||
if (state.enabled && state.stalledPermission) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_paused_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_paused_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
|
||||
) { Text(stringResource(R.string.settings_special_dates_grant)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_enable),
|
||||
summary = stringResource(R.string.settings_special_dates_enable_hint),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.enabled,
|
||||
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
|
||||
)
|
||||
},
|
||||
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
|
||||
)
|
||||
|
||||
if (state.enabled) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
// Per-type toggles + their title templates.
|
||||
SpecialDateType.entries.forEachIndexed { index, type ->
|
||||
val on = type in state.types
|
||||
GroupedRow(
|
||||
title = stringResource(specialDateTypeLabel(type)),
|
||||
position = if (index == 0) Position.Top else Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = on,
|
||||
onCheckedChange = { want ->
|
||||
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
|
||||
else confirmDisableType = type
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
|
||||
},
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_template),
|
||||
summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(),
|
||||
position = Position.Bottom,
|
||||
onClick = { editTemplate = SpecialDateType.Birthday },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_show_age),
|
||||
summary = stringResource(R.string.settings_special_dates_show_age_hint),
|
||||
position = Position.Top,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showAge,
|
||||
onCheckedChange = viewModel::setSpecialDatesShowAge,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setSpecialDatesShowAge(!state.showAge) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_sync_now),
|
||||
summary = specialDatesLastRunLabel(context, state.lastRun),
|
||||
position = Position.Bottom,
|
||||
onClick = viewModel::syncSpecialDatesNow,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_calendar_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmDisableAll) {
|
||||
SpecialDatesDisableDialog(
|
||||
message = stringResource(R.string.settings_special_dates_disable_all_message),
|
||||
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
|
||||
onDismiss = { confirmDisableAll = false },
|
||||
)
|
||||
}
|
||||
confirmDisableType?.let { type ->
|
||||
SpecialDatesDisableDialog(
|
||||
message = stringResource(
|
||||
R.string.settings_special_dates_disable_type_message,
|
||||
stringResource(specialDateTypeLabel(type)),
|
||||
),
|
||||
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
|
||||
onDismiss = { confirmDisableType = null },
|
||||
)
|
||||
}
|
||||
editTemplate?.let { type ->
|
||||
SpecialDatesTemplateDialog(
|
||||
initial = state.titleTemplates[type].orEmpty(),
|
||||
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
|
||||
onDismiss = { editTemplate = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesDisableDialog(
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesTemplateDialog(
|
||||
initial: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var text by rememberSaveable { mutableStateOf(initial) }
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||
text = {
|
||||
Column {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_template_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = text.isNotBlank(),
|
||||
) { Text(stringResource(R.string.dialog_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
|
||||
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
|
||||
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
|
||||
}
|
||||
|
||||
private fun hasContactsPermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@Composable
|
||||
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||
if (lastRun <= 0L) {
|
||||
stringResource(R.string.settings_special_dates_never_synced)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.settings_special_dates_last_synced,
|
||||
android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||
lastRun,
|
||||
System.currentTimeMillis(),
|
||||
android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
/** A global default (null = none) as a picker choice for selection highlighting. */
|
||||
private fun Int?.toReminderChoice(): CalendarReminderOverride =
|
||||
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
||||
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||
|
||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
|
||||
(this as? CalendarReminderOverride.Minutes)?.minutes
|
||||
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
||||
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
@@ -1077,27 +1376,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||
}
|
||||
|
||||
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
this[calendarId] == null -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
|
||||
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
|
||||
}
|
||||
|
||||
/** Label for a global-default choice: null → "None", else the lead time. */
|
||||
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||
@Composable
|
||||
private fun reminderChoiceLabel(minutes: Int?): String =
|
||||
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
||||
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
|
||||
// than a (non-composable) joinToString transform.
|
||||
val parts = ArrayList<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. */
|
||||
@Composable
|
||||
private fun calendarOverrideSummary(
|
||||
choice: CalendarReminderOverride,
|
||||
globalDefault: Int?,
|
||||
globalDefault: List<Int>,
|
||||
): String = when (choice) {
|
||||
CalendarReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1170,6 +1475,26 @@ private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||
},
|
||||
)
|
||||
|
||||
/** A small primary-coloured group label, matching the Calendars settings screen. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
|
||||
when (mode) {
|
||||
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(tag: String?): String =
|
||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
@@ -24,6 +26,10 @@ data class SettingsUiState(
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** How the Agenda screen treats events that already ended today. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
@@ -39,24 +45,24 @@ data class SettingsUiState(
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
val remindersEnabled: Boolean = true,
|
||||
/**
|
||||
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||
* null = no default reminder. Per-calendar overrides take precedence.
|
||||
* The default reminder lead times (minutes) prefilled on new timed events;
|
||||
* empty = no default reminder. Per-calendar overrides take precedence.
|
||||
*/
|
||||
val defaultReminderMinutes: Int? = null,
|
||||
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||
val defaultAllDayReminderMinutes: Int? = null,
|
||||
val defaultReminderMinutes: List<Int> = emptyList(),
|
||||
/** The default reminder lead times prefilled on new all-day events; empty = none. */
|
||||
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
|
||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||
* calendar present in the map overrides the global default (null value = no
|
||||
* calendar present in the map overrides the global default (empty value = no
|
||||
* reminder); absent = inherit the global default.
|
||||
*/
|
||||
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/**
|
||||
@@ -65,3 +71,20 @@ data class SettingsUiState(
|
||||
*/
|
||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* State for the contact special-dates sub-page (issue #15). Exposed as its own
|
||||
* flow rather than folded into [SettingsUiState] (whose combine is already full).
|
||||
* [titleTemplates] is always populated — a blank stored template resolves to the
|
||||
* localized default so the editor never shows an empty field.
|
||||
*/
|
||||
data class SpecialDatesUiState(
|
||||
val enabled: Boolean = false,
|
||||
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||
val showAge: Boolean = true,
|
||||
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||
val stalledPermission: Boolean = false,
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long = 0L,
|
||||
)
|
||||
|
||||
@@ -10,16 +10,26 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
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
|
||||
@@ -39,6 +49,9 @@ import javax.inject.Inject
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -99,6 +112,8 @@ class SettingsViewModel @Inject constructor(
|
||||
combine(
|
||||
prefs.agendaShowRangeBar,
|
||||
prefs.autofocusEventTitle,
|
||||
prefs.pastEventDisplay,
|
||||
prefs.dimCompletedEvents,
|
||||
::MiscSettings,
|
||||
),
|
||||
) { base, defaults, overrides, views, misc ->
|
||||
@@ -110,6 +125,8 @@ class SettingsViewModel @Inject constructor(
|
||||
showHourLines = views.showHourLines,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -133,15 +150,15 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val allowColor: Boolean,
|
||||
val defaultReminder: Int?,
|
||||
val allDayReminder: Int?,
|
||||
val defaultReminder: List<Int>,
|
||||
val allDayReminder: List<Int>,
|
||||
val allDayReminderTime: Int,
|
||||
val snoozeMinutes: Int,
|
||||
)
|
||||
|
||||
private data class ReminderOverrides(
|
||||
val timed: Map<Long, Int?>,
|
||||
val allDay: Map<Long, Int?>,
|
||||
val timed: Map<Long, List<Int>>,
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
)
|
||||
|
||||
@@ -156,8 +173,81 @@ class SettingsViewModel @Inject constructor(
|
||||
private data class MiscSettings(
|
||||
val showRangeBar: Boolean,
|
||||
val autofocusEventTitle: Boolean,
|
||||
val pastEventDisplay: PastEventDisplay,
|
||||
val dimCompletedEvents: Boolean,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
|
||||
prefs.specialDatesEnabled,
|
||||
prefs.specialDatesTypes,
|
||||
prefs.specialDatesTitleTemplates,
|
||||
prefs.specialDatesShowAge,
|
||||
prefs.specialDatesStatus,
|
||||
) { enabled, types, templates, showAge, status ->
|
||||
SpecialDatesUiState(
|
||||
enabled = enabled,
|
||||
types = types,
|
||||
// A blank stored template resolves to the localized default so the
|
||||
// editor always shows something sensible.
|
||||
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||
templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: specialDatesSpec.defaultTitleTemplate(type)
|
||||
},
|
||||
showAge = showAge,
|
||||
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||
lastRun = status.lastRun,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SpecialDatesUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Turn the mirror on or off. Enabling schedules the daily job and runs an
|
||||
* immediate sync (which creates the calendars); disabling cancels the job and
|
||||
* tears the managed calendars down. The caller must have READ_CONTACTS granted
|
||||
* before enabling.
|
||||
*/
|
||||
fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesEnabled(enabled)
|
||||
if (enabled) {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = true)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
} else {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = false)
|
||||
withContext(io) { specialDatesEngine.teardown() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDateTypeEnabled(type, enabled)
|
||||
// Reconcile now: create the new type's calendar, or delete a disabled one.
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesTitleTemplate(type, template.trim())
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesShowAge(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesShowAge(enabled)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick off an immediate reconcile (the "Sync now" action). */
|
||||
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -211,6 +301,26 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
// The agenda widget honours this setting too, so push it into each
|
||||
// instance's Glance state and recompose — same reliable-update path as
|
||||
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDimCompletedEvents(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
@@ -227,11 +337,11 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -73,11 +76,15 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -129,6 +136,14 @@ fun WeekScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -221,19 +236,21 @@ fun WeekScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,8 +567,10 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
@@ -692,6 +711,7 @@ private fun DayColumnCard(
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
@@ -712,6 +732,7 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -720,9 +741,26 @@ private fun EventBlock(
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
||||
// whole block, mirroring Google Calendar.
|
||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||
// single character on slim, overlapping blocks.
|
||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
@@ -732,18 +770,15 @@ private fun EventBlock(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||
// wrap to a second line (after the dash) instead of clipping the
|
||||
// end time.
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
|
||||
@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
@@ -24,6 +25,7 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How far either side of today the month widget pre-loads. The displayed month
|
||||
@@ -52,6 +54,8 @@ sealed interface AgendaWidgetData {
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||
* in the composition, against [now], for the same reliable-update reason.
|
||||
*/
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
@@ -60,6 +64,10 @@ sealed interface AgendaWidgetData {
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
@@ -108,6 +116,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
val ep = widgetEntryPoint()
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
@@ -122,6 +131,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
is24Hour = is24Hour,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,11 +43,15 @@ import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
@@ -79,6 +83,14 @@ import java.util.Locale
|
||||
*/
|
||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the past-event display mode (as
|
||||
* [PastEventDisplay.name]). Read reactively in the composition for the same
|
||||
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
|
||||
* widget without depending on the `provideGlance` preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
@@ -125,11 +137,26 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
// to the saved pref for a freshly placed widget), then the wide
|
||||
// pre-loaded window is sliced to it — pure recomposition.
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val pastDisplay =
|
||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val visibleDays = data.days.filter { it.date <= rangeEnd }
|
||||
// Slice to the chosen range, then drop finished events (and any day
|
||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||
val visibleDays = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
@@ -143,7 +170,13 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
dark = dark,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,9 +257,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = pastelize(event.color, dark).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
@@ -249,14 +288,14 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
||||
.width(5.dp)
|
||||
.height(36.dp)
|
||||
.cornerRadius(3.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, is24Hour),
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App zu Beginn nicht — und nichts verlässt je dein Gerät.</string>
|
||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||
@@ -316,7 +316,6 @@
|
||||
<string name="reminder_none">Keine</string>
|
||||
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||
<string name="reminder_custom_amount">Anzahl</string>
|
||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
||||
<string name="reminder_custom_set">Übernehmen</string>
|
||||
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||
@@ -411,4 +410,36 @@
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||
<string name="special_dates_calendar_custom">Besondere Tage</string>
|
||||
<string name="special_dates_default_title_birthday">{name}s Geburtstag ({age})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({age})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string>
|
||||
<string name="settings_special_dates_subtitle">Geburtstage & Jahrestage aus Kontakten</string>
|
||||
<string name="settings_section_special_dates">Besondere Kontakttage</string>
|
||||
<string name="settings_special_dates_enable">Kontakttage anzeigen</string>
|
||||
<string name="settings_special_dates_enable_hint">Spiegelt Geburtstage und weitere Daten deiner Kontakte in lokale Kalender. Liest Kontakte nur auf diesem Gerät — nichts wird hochgeladen, und deine Kontakte werden nie verändert.</string>
|
||||
<string name="settings_special_dates_type_birthday">Geburtstage</string>
|
||||
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
|
||||
<string name="settings_special_dates_type_custom">Weitere Tage</string>
|
||||
<string name="settings_special_dates_template">Titelformat</string>
|
||||
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist).</string>
|
||||
<string name="settings_special_dates_show_age">Alter anzeigen</string>
|
||||
<string name="settings_special_dates_show_age_hint">{age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist</string>
|
||||
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
|
||||
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
|
||||
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Farbe, Sichtbarkeit und Erinnerungen jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
|
||||
<string name="settings_special_dates_paused_title">Pausiert</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
|
||||
<string name="settings_special_dates_grant">Zugriff erlauben</string>
|
||||
<string name="settings_special_dates_disable_title">Kontakttage deaktivieren?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||
<string name="dialog_save">Speichern</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
||||
<string name="permission_request_button">Grant calendar access</string>
|
||||
<string name="permission_denied_title">Calendar access denied</string>
|
||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||
@@ -68,6 +68,8 @@
|
||||
<string name="event_edit_close">Close</string>
|
||||
<string name="event_edit_save">Save</string>
|
||||
<string name="event_edit_title_hint">Add title</string>
|
||||
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
|
||||
<string name="event_edit_starts">Starts</string>
|
||||
<string name="event_edit_ends">Ends</string>
|
||||
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||
@@ -282,6 +284,13 @@
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Hide</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
@@ -317,7 +326,6 @@
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_use_default">Use default reminder</string>
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
@@ -338,6 +346,33 @@
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="settings_section_special_dates">Contact special dates</string>
|
||||
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
|
||||
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||
<string name="settings_special_dates_template">Title format</string>
|
||||
<string name="settings_special_dates_template_hint">Use {name} for the contact and {age} for their age (hidden when the year is unknown).</string>
|
||||
<string name="settings_special_dates_show_age">Show age</string>
|
||||
<string name="settings_special_dates_show_age_hint">Include {age} in titles when the birth year is known</string>
|
||||
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour, visibility and reminders in Calendars settings.</string>
|
||||
<string name="settings_special_dates_paused_title">Paused</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||
<string name="settings_special_dates_grant">Grant access</string>
|
||||
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||
<string name="dialog_save">Save</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -456,4 +491,13 @@
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||
|
||||
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
|
||||
at the upcoming date (dropped when the year is unknown or Show age is off). -->
|
||||
<string name="special_dates_calendar_birthday">Birthdays</string>
|
||||
<string name="special_dates_calendar_anniversary">Anniversaries</string>
|
||||
<string name="special_dates_calendar_custom">Special dates</string>
|
||||
<string name="special_dates_default_title_birthday">{name}\'s birthday ({age})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({age})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -199,11 +199,12 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
// A single occurrence never carries its own rule.
|
||||
// The occurrence's length travels as DURATION, never DTEND — the provider
|
||||
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
|
||||
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun deleteCalendar(id: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedCalendarIds += id
|
||||
managedCalendarRows.removeAll { it.id == id }
|
||||
managedEvents.remove(id)
|
||||
}
|
||||
|
||||
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||
@@ -139,6 +144,68 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedEventIds += eventId
|
||||
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
|
||||
}
|
||||
|
||||
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
|
||||
|
||||
var nextManagedEventId: Long = 1_000L
|
||||
private var managedCalendarSeq: Long = 700L
|
||||
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
|
||||
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
|
||||
|
||||
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
|
||||
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
|
||||
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
|
||||
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
|
||||
|
||||
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
|
||||
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
|
||||
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||
managedEvents.getOrPut(id) { mutableListOf() }
|
||||
}
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
writeError?.let { throw it }
|
||||
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
|
||||
val id = managedCalendarSeq++
|
||||
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||
managedEvents.getOrPut(id) { mutableListOf() }
|
||||
return id
|
||||
}
|
||||
|
||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
|
||||
|
||||
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
|
||||
managedEvents[calendarId]?.toList() ?: emptyList()
|
||||
|
||||
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||
writeError?.let { throw it }
|
||||
insertedManagedEvents += form to uid
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
val id = nextManagedEventId++
|
||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
|
||||
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
|
||||
return id
|
||||
}
|
||||
|
||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||
writeError?.let { throw it }
|
||||
if (values.isEmpty()) return
|
||||
updatedManagedFields += eventId to values
|
||||
managedEvents.values.forEach { rows ->
|
||||
val idx = rows.indexOfFirst { it.eventId == eventId }
|
||||
if (idx >= 0) {
|
||||
var row = rows[idx]
|
||||
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
|
||||
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
|
||||
if (values.containsKey(CalendarContract.Events.RRULE)) {
|
||||
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
|
||||
}
|
||||
rows[idx] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class SpecialDatesSyncEngineTest {
|
||||
|
||||
private val today = LocalDate(2026, 3, 1)
|
||||
|
||||
private class FakeContacts(
|
||||
var permission: Boolean = true,
|
||||
var dates: List<ContactSpecialDate> = emptyList(),
|
||||
) : ContactSpecialDatesDataSource {
|
||||
override fun hasPermission() = permission
|
||||
override fun readSpecialDates() = dates
|
||||
}
|
||||
|
||||
private class FakeSpec : SpecialDatesCalendarSpec {
|
||||
override fun displayName(type: SpecialDateType) = type.name
|
||||
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
|
||||
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
|
||||
SpecialDateType.Birthday -> "{name}'s birthday ({age})"
|
||||
SpecialDateType.Anniversary -> "{name}'s anniversary"
|
||||
SpecialDateType.Custom -> "{name}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefs(tempDir: Path): SettingsPrefs {
|
||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||
)
|
||||
return SettingsPrefs(store)
|
||||
}
|
||||
|
||||
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
|
||||
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
|
||||
|
||||
private suspend fun engineWith(
|
||||
tempDir: Path,
|
||||
contacts: FakeContacts,
|
||||
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
|
||||
enabled: Boolean = true,
|
||||
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
|
||||
val settings = prefs(tempDir)
|
||||
if (enabled) settings.setSpecialDatesEnabled(true)
|
||||
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
|
||||
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Disabled)
|
||||
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
|
||||
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
|
||||
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(
|
||||
dates = listOf(
|
||||
birthday("a", "Jane", 5, 14, 1990),
|
||||
birthday("b", "Bob", 12, 25, null),
|
||||
),
|
||||
)
|
||||
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||
|
||||
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Success)
|
||||
// All three type calendars exist; the birthday one holds both contacts.
|
||||
assertThat(calendars.createdManagedCalendars.map { it.type })
|
||||
.containsExactlyElementsIn(SpecialDateType.entries)
|
||||
assertThat(calendars.insertedManagedEvents).hasSize(2)
|
||||
assertThat(calendars.insertedManagedEvents.map { it.second })
|
||||
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
|
||||
// Year-less contact drops the age; year-known keeps it.
|
||||
val titles = calendars.insertedManagedEvents.map { it.first.title }
|
||||
assertThat(titles).contains("Jane's birthday (36)")
|
||||
assertThat(titles).contains("Bob's birthday")
|
||||
// Calendar ids were persisted for the editor / next sync.
|
||||
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||
|
||||
engine.sync(today)
|
||||
val insertsAfterFirst = calendars.insertedManagedEvents.size
|
||||
val calendarsAfterFirst = calendars.createdManagedCalendars.size
|
||||
engine.sync(today)
|
||||
|
||||
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
|
||||
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
|
||||
assertThat(calendars.updatedManagedFields).isEmpty()
|
||||
assertThat(calendars.deletedEventIds).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||
engine.sync(today)
|
||||
|
||||
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
|
||||
engine.sync(today)
|
||||
|
||||
assertThat(calendars.updatedManagedFields).hasSize(1)
|
||||
val (_, columns) = calendars.updatedManagedFields.single()
|
||||
// Only managed columns are ever written — never reminders/location.
|
||||
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
|
||||
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (36)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||
engine.sync(today)
|
||||
|
||||
contacts.dates = emptyList()
|
||||
engine.sync(today)
|
||||
|
||||
assertThat(calendars.deletedEventIds).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||
engine.sync(today)
|
||||
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
|
||||
|
||||
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||
engine.sync(today)
|
||||
|
||||
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
|
||||
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
|
||||
}
|
||||
|
||||
// --- pure diff ---
|
||||
|
||||
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||
ManagedEventDesired(uid, title, dt, rrule)
|
||||
|
||||
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||
ManagedEventRow(id, uid, title, dt, rrule)
|
||||
|
||||
@Test
|
||||
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
|
||||
val diff = diffManagedEvents(
|
||||
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
|
||||
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
|
||||
)
|
||||
assertThat(diff.insertUids).containsExactly("new")
|
||||
assertThat(diff.deleteEventIds).containsExactly(2L)
|
||||
assertThat(diff.updates).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diff emits only the changed managed columns`() {
|
||||
val diff = diffManagedEvents(
|
||||
desired = listOf(desired("a", "Janet", 150)),
|
||||
existing = listOf(row(9, "a", "Jane", 100)),
|
||||
)
|
||||
val update = diff.updates.single()
|
||||
assertThat(update.eventId).isEqualTo(9L)
|
||||
assertThat(update.columns.keys)
|
||||
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -183,22 +184,38 @@ class SettingsPrefsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
|
||||
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setDefaultReminderMinutes(30)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
|
||||
prefs.setDefaultReminderMinutes(null)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
prefs.setDefaultReminderMinutes(listOf(30))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||
// Several lead times persist, normalised to distinct ascending order.
|
||||
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
|
||||
prefs.setDefaultReminderMinutes(emptyList())
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
|
||||
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
// The pre-multi-reminder format stored a bare integer string.
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
@@ -206,7 +223,7 @@ class SettingsPrefsTest {
|
||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -214,16 +231,16 @@ class SettingsPrefsTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15, 9L, null)
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
||||
}
|
||||
|
||||
// Inherit drops the override entirely (absent != null value).
|
||||
// Inherit drops the override entirely (absent != an empty-list value).
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15)
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
||||
assertThat(map.containsKey(9L)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -231,48 +248,48 @@ class SettingsPrefsTest {
|
||||
@Test
|
||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
prefs.setDefaultAllDayReminderMinutes(1_440)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
|
||||
prefs.setDefaultAllDayReminderMinutes(null)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
|
||||
prefs.setDefaultAllDayReminderMinutes(emptyList())
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
||||
// Clearing the all-day override leaves the timed one untouched.
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
||||
val timed = mapOf(7L to 15, 9L to null)
|
||||
val allDay = mapOf(7L to 2_880)
|
||||
val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
|
||||
val allDay = mapOf(7L to listOf(2_880))
|
||||
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
||||
timedGlobal = 30,
|
||||
allDayGlobal = 1_440,
|
||||
timedGlobal = listOf(30),
|
||||
allDayGlobal = listOf(1_440),
|
||||
timedOverrides = timed,
|
||||
allDayOverrides = allDay,
|
||||
calendarId = calendarId,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
// Timed: minutes override, explicit none, inherit global, no calendar.
|
||||
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
|
||||
assertThat(resolve(9L, isAllDay = false)).isNull()
|
||||
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
|
||||
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
|
||||
// Timed: multi-value override, explicit none, inherit global, no calendar.
|
||||
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
|
||||
assertThat(resolve(9L, isAllDay = false)).isEmpty()
|
||||
assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
|
||||
assertThat(resolve(null, isAllDay = false)).containsExactly(30)
|
||||
// All-day: its own override wins; absent → all-day global; a timed-only
|
||||
// override (cal 9) does not bleed into all-day.
|
||||
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
|
||||
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
|
||||
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
|
||||
assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
|
||||
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
|
||||
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -324,4 +341,59 @@ class SettingsPrefsTest {
|
||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesEnabled.first()).isFalse()
|
||||
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
|
||||
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
|
||||
assertThat(prefs.managedCalendarIds.first()).isEmpty()
|
||||
assertThat(prefs.specialDatesShowAge.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||
assertThat(prefs.specialDatesTypes.first())
|
||||
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
|
||||
assertThat(prefs.specialDatesCalendars.first())
|
||||
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
|
||||
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
|
||||
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
|
||||
assertThat(prefs.specialDatesCalendars.first())
|
||||
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
|
||||
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
|
||||
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
|
||||
.isEqualTo("{name}'s birthday")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesStatus.first())
|
||||
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
|
||||
|
||||
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
|
||||
assertThat(prefs.specialDatesStatus.first())
|
||||
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
|
||||
|
||||
prefs.recordSpecialDatesRun(2000L, null)
|
||||
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PostableAlertsTest {
|
||||
|
||||
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
|
||||
alertId = alertId,
|
||||
eventId = alertId * 10,
|
||||
calendarId = calendarId,
|
||||
beginMillis = 0L,
|
||||
endMillis = 0L,
|
||||
title = "Event $alertId",
|
||||
location = null,
|
||||
isAllDay = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `keeps alerts when no calendar is disabled`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops alerts for a disabled calendar`() {
|
||||
val keep = alert(1, calendarId = 100)
|
||||
val drop = alert(2, calendarId = 200)
|
||||
|
||||
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
|
||||
|
||||
assertThat(postable).containsExactly(keep)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops every alert when all their calendars are disabled`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
|
||||
|
||||
assertThat(postable).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps multiple alerts from the same enabled calendar`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ContactSpecialDateTest {
|
||||
|
||||
@Test
|
||||
fun `parses a full yyyy-MM-dd date with year`() {
|
||||
val parts = parseContactEventDate("1990-05-14")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses a year-less --MM-dd date`() {
|
||||
val parts = parseContactEventDate("--05-14")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses a compact yyyyMMdd date`() {
|
||||
val parts = parseContactEventDate("19900514")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses a year-less MM-dd date without the double-dash prefix`() {
|
||||
val parts = parseContactEventDate("12-25")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
|
||||
val parts = parseContactEventDate("--02-29")
|
||||
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps Feb 29 in a real leap year`() {
|
||||
assertThat(parseContactEventDate("2000-02-29"))
|
||||
.isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects Feb 29 in a non-leap year`() {
|
||||
assertThat(parseContactEventDate("1999-02-29")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an impossible month or day`() {
|
||||
assertThat(parseContactEventDate("2020-13-01")).isNull()
|
||||
assertThat(parseContactEventDate("2020-06-31")).isNull()
|
||||
assertThat(parseContactEventDate("--00-10")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects blank, null and garbage input`() {
|
||||
assertThat(parseContactEventDate(null)).isNull()
|
||||
assertThat(parseContactEventDate("")).isNull()
|
||||
assertThat(parseContactEventDate(" ")).isNull()
|
||||
assertThat(parseContactEventDate("not-a-date")).isNull()
|
||||
assertThat(parseContactEventDate("2020")).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SpecialDateFormattingTest {
|
||||
|
||||
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
|
||||
lookupKey = "k",
|
||||
displayName = "Jane",
|
||||
type = SpecialDateType.Birthday,
|
||||
month = month,
|
||||
day = day,
|
||||
year = year,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `uid is deterministic and namespaced by type`() {
|
||||
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
|
||||
.isEqualTo("contact-birthday:abc@calendula")
|
||||
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
|
||||
.isEqualTo("contact-anniversary:abc@calendula")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anchor date uses the known year`() {
|
||||
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anchor date falls back to a leap year when year-less`() {
|
||||
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `age is null when the year is unknown`() {
|
||||
assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `age counts the upcoming birthday when it is still ahead this year`() {
|
||||
// Born 1990-05-14; today 2026-03-01 → next birthday 2026 → turns 36.
|
||||
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 3, 1))).isEqualTo(36)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `age rolls to next year once the birthday has passed`() {
|
||||
// Born 1990-05-14; today 2026-06-01 → next birthday 2027 → turns 37.
|
||||
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 6, 1))).isEqualTo(37)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `age counts today's birthday as this year`() {
|
||||
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 5, 14))).isEqualTo(36)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title substitutes name and age`() {
|
||||
assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", 30))
|
||||
.isEqualTo("Jane's birthday (30)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title drops an empty age parenthesis and tidies spacing`() {
|
||||
assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", null))
|
||||
.isEqualTo("Jane's birthday")
|
||||
}
|
||||
}
|
||||
246
docs/design/contact-special-dates.md
Normal file
246
docs/design/contact-special-dates.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
|
||||
|
||||
Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
|
||||
Date: 2026-06-30 (implemented 2026-07-01)
|
||||
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
|
||||
|
||||
Implementation notes / deviations from this design:
|
||||
|
||||
- **Event identity key is a deterministic `Events.UID_2445`**
|
||||
(`contact-<type>:<lookupKey>@calendula`), not `SYNC_DATA1`. On a LOCAL calendar
|
||||
the provider drops `_sync` columns unless written through a sync-adapter URI;
|
||||
`UID_2445` is written/read through the normal event URIs the app already uses
|
||||
(and doubles as a stable iCal UID). The managed **calendar** still carries a
|
||||
`CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
|
||||
sync-adapter URI).
|
||||
- **Age is a sync-time snapshot.** A `FREQ=YEARLY` row has one static title, so
|
||||
`{age}` reflects the upcoming birthday at the last sync (refreshed daily / on
|
||||
foreground), not a per-occurrence value.
|
||||
- New managed calendars seed a per-calendar all-day reminder default of
|
||||
on-the-day + one week before, so birthdays get lead time out of the box.
|
||||
- Title template is user-editable from day one (`{name}`, `{age}`).
|
||||
|
||||
This document captures the full design for surfacing contact birthdays —
|
||||
and, by extension, anniversaries and custom dates — as auto-updating local
|
||||
calendars in Calendula. It also specifies #14 (per-calendar *multiple*
|
||||
default reminders), which is a hard prerequisite and ships first.
|
||||
|
||||
The two pieces are deliberately split across **two branches / two
|
||||
shipments**:
|
||||
|
||||
- `feat/per-calendar-multi-reminders` — #14, ships first.
|
||||
- `feat/contact-special-dates` — #15, builds on #14.
|
||||
|
||||
They *may* release together depending on timing, but #14 is independently
|
||||
useful and is the foundation the special-dates calendars lean on.
|
||||
|
||||
---
|
||||
|
||||
## Why this is worth doing carefully
|
||||
|
||||
Calendula's identity is privacy: **no INTERNET permission**, and historically
|
||||
**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
|
||||
never holds the contacts permission (`EventEditScreen.kt:446-460`).
|
||||
|
||||
This feature **declares `READ_CONTACTS`** for the first time. That is a
|
||||
deliberate, owner-approved shift, mitigated by:
|
||||
|
||||
- The permission is **opt-in and feature-gated** — requested only when the user
|
||||
enables the feature, never at startup.
|
||||
- Everything stays **offline**. Contacts are read locally and mirrored into a
|
||||
local calendar; nothing leaves the device.
|
||||
- The mirror is **one-way** (contacts → calendar). Calendula never writes to
|
||||
Contacts.
|
||||
|
||||
Store listing / About copy will need to explain the optional permission.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisite — #14: per-calendar *multiple* default reminders
|
||||
|
||||
### Today
|
||||
|
||||
- Events already support **multiple reminders** — `EventForm.reminders:
|
||||
List<Int>` (`EventForm.kt:24`), each written as a separate
|
||||
`CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each
|
||||
firing independently. The runtime/notification path is done.
|
||||
- The **defaults** layer is single-valued, though:
|
||||
- Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single
|
||||
`Int` (`SettingsPrefs.kt:287,302`).
|
||||
- Per-calendar override: `perCalendarReminderOverride: Map<Long, Int?>` and
|
||||
the all-day variant (`SettingsPrefs.kt:350-381`), resolved by
|
||||
`resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`).
|
||||
- UI: expandable per-calendar override section
|
||||
(`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`).
|
||||
|
||||
### The change
|
||||
|
||||
Widen the **defaults**, not the runtime, from one reminder to a list:
|
||||
|
||||
1. Resolution model `Int` → `List<Int>` in global defaults + per-calendar
|
||||
override + `resolveDefaultReminder()`. The DataStore string format already
|
||||
parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
|
||||
comma-separated list. Keep the `Inherit / None / Minutes` override states
|
||||
(`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
|
||||
a list.
|
||||
2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
|
||||
UI rather than inventing new interaction.
|
||||
3. New events seed `EventForm.reminders` from the **resolved list** instead of
|
||||
a single value.
|
||||
|
||||
No new permissions, no provider changes, no scheduling changes. Type widening
|
||||
plus one picker upgrade.
|
||||
|
||||
### Why it gates #15
|
||||
|
||||
Each special-dates calendar is a normal local calendar, so it inherits the
|
||||
**per-calendar reminder default** for free. A birthdays calendar is useless
|
||||
without lead time (the reporter literally asked for "a week before *and* on the
|
||||
day") — that requires *multiple* defaults, i.e. #14.
|
||||
|
||||
---
|
||||
|
||||
## #15: Contact special-dates calendars
|
||||
|
||||
### Core model: one-way mirror, one local calendar per type
|
||||
|
||||
Contacts → **separate local calendars per type**:
|
||||
|
||||
- "Birthdays"
|
||||
- "Anniversaries"
|
||||
- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
|
||||
|
||||
Separate-per-type is the key architectural decision: each is a normal local
|
||||
calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
|
||||
**existing per-calendar infra applies for free**:
|
||||
|
||||
- **Color** → existing per-calendar color.
|
||||
- **Show/hide** → existing calendar visibility.
|
||||
- **Default reminders** → per-calendar override from #14.
|
||||
|
||||
This shrinks the new surface dramatically: anniversaries and custom dates are
|
||||
near copy-paste of birthdays (same engine, different `ContactsContract` data
|
||||
kind, one extra local calendar each), and the settings sub-page does **not**
|
||||
need to reinvent color/visibility/reminders.
|
||||
|
||||
### Events: field-level managed, not read-only
|
||||
|
||||
Calendula manages a few fields; the user owns the rest.
|
||||
|
||||
- **Managed** (overwritten on every sync, disabled in the editor for these
|
||||
events): title, start date, recurrence (`FREQ=YEARLY`), existence.
|
||||
- **User-owned** (seeded once on insert, never touched again by sync):
|
||||
location, reminders, notes, busy/free.
|
||||
|
||||
The rule that makes this safe: sync performs a **targeted column update**
|
||||
(managed columns only), **never delete-and-reinsert**. Reminders live in
|
||||
separate rows, so once seeded they are simply never re-touched. A user can move
|
||||
a birthday reminder to "2 weeks before" or add a location and neither is
|
||||
clobbered.
|
||||
|
||||
In the editor, when an event belongs to a managed calendar: disable the
|
||||
title/date/recurrence fields; allow the rest.
|
||||
|
||||
### Reconciliation identity
|
||||
|
||||
Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
|
||||
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
|
||||
|
||||
- contact added → insert
|
||||
- birthday/date changed → targeted update of managed columns
|
||||
- contact or date removed → delete the event
|
||||
|
||||
Getting this wrong = duplicate birthdays on every sync, so the identity key is
|
||||
non-negotiable.
|
||||
|
||||
### Data source
|
||||
|
||||
`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
|
||||
`Event.TYPE`:
|
||||
|
||||
- `TYPE_BIRTHDAY` → Birthdays calendar
|
||||
- `TYPE_ANNIVERSARY` → Anniversaries calendar
|
||||
- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
|
||||
|
||||
### Dates: year-less + age
|
||||
|
||||
Many contacts store `--MM-DD` (no year). Handle both:
|
||||
|
||||
- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
|
||||
age ("Jane turns 30") when the show-age option is on.
|
||||
- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
|
||||
|
||||
Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
|
||||
(`Recurrence.kt:32-174`).
|
||||
|
||||
### Auto-update cadence
|
||||
|
||||
Birthdays change rarely, so nothing live/expensive:
|
||||
|
||||
- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
|
||||
(catches new/edited/deleted contacts even if the app isn't opened).
|
||||
- **No `ContentObserver`** — needs the process alive and buys almost nothing
|
||||
for once-a-year events.
|
||||
|
||||
### Permission + lifecycle
|
||||
|
||||
- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
|
||||
contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
|
||||
grant, create the enabled calendars and run the first sync.
|
||||
- **Disable:** delete the managed calendars + their events (with a confirm).
|
||||
- **Permission revoked in system settings:** detect on next sync; surface that
|
||||
the feature is stalled rather than failing silently.
|
||||
|
||||
### Configurable settings sub-page
|
||||
|
||||
Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
|
||||
color/visibility/reminders already live in normal calendar settings, the
|
||||
sub-page only carries the genuinely new knobs:
|
||||
|
||||
- Master enable toggle.
|
||||
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
|
||||
- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
|
||||
- Show-age toggle (only meaningful when the year is known).
|
||||
- (Possibly) sync-cadence / "sync now".
|
||||
- Pointer to the normal calendar settings for color, visibility, reminders.
|
||||
|
||||
The owner wants this broadly configurable — keep the sub-page the home for
|
||||
display/behaviour options, and lean on existing per-calendar settings for the
|
||||
rest.
|
||||
|
||||
---
|
||||
|
||||
## Open questions / deferred
|
||||
|
||||
- Title template: free-form vs a small set of presets. Start with one good
|
||||
localized default; revisit configurability.
|
||||
- Whether "Custom dates" is one bucket or split further by label. Start: one
|
||||
bucket.
|
||||
- Contact scope: all contacts vs per-account/group filtering. Start: all;
|
||||
filtering is a later refinement.
|
||||
|
||||
## Task outline
|
||||
|
||||
### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
|
||||
- [x] Widen global + per-calendar reminder defaults `Int` → `List<Int>`
|
||||
(`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
|
||||
- [x] `resolveDefaultReminder()` returns a list.
|
||||
- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
|
||||
- [x] Seed new events' `EventForm.reminders` from the resolved list.
|
||||
- [x] Migration / parse compatibility for existing single-value stored prefs.
|
||||
- [x] Tests: resolution, parse round-trip, all-day variant.
|
||||
|
||||
### #15 — contact special-dates (`feat/contact-special-dates`)
|
||||
- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
|
||||
- [x] Per-type local calendars (create on enable, delete on disable+confirm).
|
||||
- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
|
||||
- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
|
||||
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
|
||||
user-owned fields on insert only.
|
||||
- [x] `FREQ=YEARLY` event generation + age (when year known; sync-time snapshot).
|
||||
- [x] Auto-update: on enable, on foreground, daily WorkManager.
|
||||
- [x] Editor: disable managed fields for managed-calendar events.
|
||||
- [x] Revoked-permission detection + stalled-state surface.
|
||||
- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
|
||||
- [x] Translatable strings (titles, sub-page).
|
||||
- [x] About / store copy for the optional `READ_CONTACTS` permission.
|
||||
@@ -48,19 +48,6 @@ Builds:
|
||||
# No NDK / no flavors. The release buildType applies a signingConfig only
|
||||
# when key.properties exists; on the buildserver it does not, so this
|
||||
# produces the unsigned APK F-Droid compares against our binary.
|
||||
#
|
||||
# DO NOT add `submodules: true` here ahead of need. fdroidserver's
|
||||
# initsubmodules() RAISES NoSubmodulesException ("No git submodules
|
||||
# available") on any build entry whose commit has no `.gitmodules`, so
|
||||
# `submodules: true` is a HARD BUILD FAILURE — not a no-op — on every
|
||||
# release before the floret-kit submodule lands. (Verified 2026-06-28: it
|
||||
# broke the fdroid build / checkupdates / check-source-code jobs on
|
||||
# fdroiddata MR !41553; confirmed locally with the fdroidserver-master
|
||||
# fetchsrclibs path.) Add `submodules: true` ONLY in the recipe entry that
|
||||
# targets the FIRST release actually shipping the `floret-kit` git submodule
|
||||
# (composite build via `includeBuild("floret-kit")`, core-time refactor on
|
||||
# branch `chore/floret-kit-core-time`) — and only there. From that entry on
|
||||
# it becomes the AutoUpdateMode template and propagates to future builds.
|
||||
|
||||
# SHA-256 of our app signing certificate (public; embedded in every published
|
||||
# APK). Locks F-Droid to publish only binaries signed with our key.
|
||||
|
||||
@@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
|
||||
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
||||
Termin.
|
||||
|
||||
Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage
|
||||
deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte
|
||||
werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert —
|
||||
Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll
|
||||
kontrollierst.
|
||||
|
||||
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
||||
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
||||
|
||||
|
||||
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
@@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a
|
||||
simple repeat picker. Calendula also delivers your event reminders as
|
||||
notifications — tap one and you're on the event.
|
||||
|
||||
Optionally, show your contacts' birthdays, anniversaries and other special
|
||||
dates in your calendar. You turn this on yourself; contacts are read only on
|
||||
your device, are never uploaded, and are never changed — Calendula only
|
||||
mirrors the dates into local calendars you fully control.
|
||||
|
||||
The differentiator is the design: real Material 3 Expressive throughout,
|
||||
with dynamic color, expressive motion, and expressive shapes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user