Compare commits
15 Commits
fix/caldav
...
4e125e58d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e125e58d5 | |||
| 52816b327a | |||
| cbb241bc4d | |||
| 0fd579b19b | |||
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
| 25ff7ce7ca |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -8,35 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||
birthdays, anniversaries and other dates into local "Birthdays",
|
||||
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||
reminders the usual way — new birthdays even start with a reminder a week before
|
||||
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||
This is the first feature to use the contacts permission: it is requested only
|
||||
when you turn the feature on, everything stays on your device (Calendula has no
|
||||
internet access), and your contacts are only ever read, never changed. Thanks
|
||||
to @moonj for the suggestion ([#15]).
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
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]).
|
||||
- Widget headers now open the app. Tapping the month/year title on the month
|
||||
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||
the agenda widget opens it on your default view — so there's a one-tap way
|
||||
back into the app that lands where you'd expect, instead of only through a day
|
||||
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||
date number — now opens that day, and the "today" button snaps the grid back
|
||||
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||
suggestions ([#18], [#20]).
|
||||
|
||||
### Fixed
|
||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||
app (such as DAVx5), the event colour picker showed every colour the account
|
||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||
duplicates or near-identical shades. The picker now shows only visually
|
||||
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||
colours still sync exactly as before, and calendars with hand-picked
|
||||
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||
([#22]).
|
||||
- Month widget arrows and "today" button work again. On release builds the
|
||||
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||
- 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
|
||||
@@ -773,8 +763,6 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -16,18 +16,6 @@
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||
# callback's fully-qualified class name into the click PendingIntent, then
|
||||
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||
# every ActionCallback's name and constructor intact.
|
||||
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||
|
||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,12 +193,6 @@ class MainActivity : AppCompatActivity() {
|
||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||
// the day view rooted over the default home view (OpenDate source = null).
|
||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||
// A widget header tap: open a top-level view with no date drill-in. The
|
||||
// empty string carried by [openViewIntent] means "the default home view".
|
||||
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||
}
|
||||
val source = sourceViewOrNull()
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
return when {
|
||||
@@ -250,10 +244,6 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||
|
||||
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||
|
||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||
@@ -317,19 +307,5 @@ class MainActivity : AppCompatActivity() {
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] opens the user's default home view (the agenda
|
||||
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||
*/
|
||||
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,16 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
@@ -58,10 +59,7 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||
* publish all ~147 CSS3 names, #22) and hue-sorted; see
|
||||
* [curatedForPicker]. Empty when the
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||
* account exposes no palette (most local calendars, some CalDAV) — the
|
||||
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
||||
* which a synced calendar may drop on its next sync.
|
||||
@@ -101,6 +99,54 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||
*/
|
||||
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||
|
||||
/**
|
||||
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||
* detect when the user deleted one in system settings.
|
||||
*/
|
||||
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||
|
||||
/**
|
||||
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||
*/
|
||||
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||
|
||||
/**
|
||||
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||
|
||||
/**
|
||||
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||
*/
|
||||
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||
|
||||
/**
|
||||
* Replace the reminders on **every** event in [calendarId] with [minutes]
|
||||
* (all-day lead times), each encoded from its own date so it fires at
|
||||
* [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder
|
||||
* change applies to existing events too, not just future ones — the managed
|
||||
* calendars treat reminders as a calendar-level setting.
|
||||
*/
|
||||
fun applyManagedCalendarReminders(
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
minutes: List<Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||
@@ -296,6 +342,160 @@ 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 applyManagedCalendarReminders(
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
minutes: List<Int>,
|
||||
) {
|
||||
val zone = ZoneId.systemDefault()
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val eventId = c.getLong(0)
|
||||
// Managed events are all-day: DTSTART is a UTC midnight.
|
||||
val startDate = Instant.ofEpochMilli(c.getLong(1))
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val encoded = minutes
|
||||
.map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) }
|
||||
.distinct()
|
||||
reconcileReminders(eventId, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
@@ -424,7 +624,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.curatedForPicker()
|
||||
?.sortedBy { it.key }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
@@ -1042,6 +1242,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"
|
||||
|
||||
@@ -1051,6 +1262,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
*/
|
||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||
|
||||
/**
|
||||
* Column and namespace for the special-dates marker. CAL_SYNC1 already
|
||||
* holds the local-calendar description, so the marker uses CAL_SYNC2.
|
||||
*/
|
||||
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||
|
||||
/**
|
||||
* How far ahead/behind a search looks for a recurring event's nearest
|
||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
|
||||
/**
|
||||
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||
*/
|
||||
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||
|
||||
/**
|
||||
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||
* the desired state to decide whether a targeted update is needed.
|
||||
*/
|
||||
data class ManagedEventRow(
|
||||
val eventId: Long,
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Resolves the localized calendar names/templates and per-type colours the
|
||||
* mirror creates its calendars with. The colours are picked from the shared
|
||||
* palette so managed calendars look native alongside user calendars.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SpecialDatesCalendarSpec {
|
||||
|
||||
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun color(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple
|
||||
SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red
|
||||
SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||
* device's contacts. Read-only and offline — the one-way source for the
|
||||
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||
* the permission is absent so the sync can degrade to a stalled state rather
|
||||
* than crash.
|
||||
*/
|
||||
interface ContactSpecialDatesDataSource {
|
||||
fun hasPermission(): Boolean
|
||||
|
||||
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||
fun readSpecialDates(): List<ContactSpecialDate>
|
||||
}
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||
else -> SpecialDateType.Custom
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ContactSpecialDatesDataSource {
|
||||
|
||||
override fun hasPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||
if (!hasPermission()) return emptyList()
|
||||
val resolver = context.contentResolver
|
||||
// A contact can carry the same date more than once (multiple raw
|
||||
// contacts under one aggregate); keep the first per (contact, type).
|
||||
val seen = HashSet<Pair<String, SpecialDateType>>()
|
||||
val result = ArrayList<ContactSpecialDate>()
|
||||
runCatching {
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
PROJECTION,
|
||||
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||
while (c.moveToNext()) {
|
||||
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||
if (!seen.add(lookup to type)) continue
|
||||
result += ContactSpecialDate(
|
||||
lookupKey = lookup,
|
||||
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||
type = type,
|
||||
month = parts.month,
|
||||
day = parts.day,
|
||||
year = parts.year,
|
||||
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||
return result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ContactSpecialDates"
|
||||
|
||||
val PROJECTION = arrayOf(
|
||||
ContactsContract.Data.LOOKUP_KEY,
|
||||
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||
Event.START_DATE,
|
||||
Event.TYPE,
|
||||
Event.LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||
*/
|
||||
object SpecialDatesScheduler {
|
||||
|
||||
private const val WORK_NAME = "special-dates-sync"
|
||||
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||
private const val 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()
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = deps.syncEngine().sync()
|
||||
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,294 @@
|
||||
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.managedEventUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
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. Returns why it
|
||||
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||
*/
|
||||
suspend fun sync(): 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 showYear = prefs.specialDatesShowYear.first()
|
||||
|
||||
calendarByType.forEach { (type, calendarId) ->
|
||||
val template = templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: spec.defaultTitleTemplate(type)
|
||||
syncType(
|
||||
calendarId = calendarId,
|
||||
type = type,
|
||||
contactsOfType = desiredByType[type].orEmpty(),
|
||||
template = template,
|
||||
showYear = showYear,
|
||||
reminderCtx = reminderCtx,
|
||||
)
|
||||
}
|
||||
return SpecialDatesSyncResult.Success
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the reminder default for a type's managed calendar and apply it to
|
||||
* **all** its existing events too (not just future ones) — for managed
|
||||
* calendars the reminder is a calendar-level setting. Persists the per-calendar
|
||||
* all-day override so new events keep matching. No-op if the calendar for
|
||||
* [type] doesn't exist yet.
|
||||
*/
|
||||
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||
val minutes = when (override) {
|
||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
CalendarReminderOverride.None -> emptyList()
|
||||
is CalendarReminderOverride.Minutes -> override.minutes
|
||||
}
|
||||
calendars.applyManagedCalendarReminders(
|
||||
calendarId = calendarId,
|
||||
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||
minutes = minutes,
|
||||
)
|
||||
}
|
||||
|
||||
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||
suspend fun teardown() {
|
||||
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||
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>,
|
||||
template: String,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
) {
|
||||
val built = contactsOfType.map { sd ->
|
||||
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||
}
|
||||
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||
val formByUid = built.associate { it.desired.uid to it.form }
|
||||
diff.insertUids.forEach { uid ->
|
||||
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||
}
|
||||
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||
}
|
||||
|
||||
private fun buildManagedEvent(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
sd: ContactSpecialDate,
|
||||
template: String,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
): BuiltManagedEvent {
|
||||
val anchor = sd.anchorDate()
|
||||
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||
// The source year is static and correct on every occurrence (unlike age).
|
||||
val year = if (showYear) sd.year else null
|
||||
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||
val form = EventForm(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
isAllDay = true,
|
||||
start = start,
|
||||
end = start,
|
||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||
availability = Availability.Free,
|
||||
rrule = YEARLY_RRULE,
|
||||
)
|
||||
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||
return BuiltManagedEvent(
|
||||
desired = ManagedEventDesired(
|
||||
uid = 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,12 +8,11 @@ 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
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -204,34 +203,6 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-switch button customisation (#24): the ordered full view list plus
|
||||
* which views are enabled in the cycle. Stored comma-joined by enum name, a
|
||||
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
|
||||
* views are appended enabled and unknown names dropped, so a future view
|
||||
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
|
||||
*/
|
||||
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
|
||||
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||
}
|
||||
|
||||
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||
* are appended in default order and unknown names dropped. Absent key means
|
||||
* [IMPLEMENTED_VIEWS] (the historical fixed order).
|
||||
*/
|
||||
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
|
||||
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
|
||||
}
|
||||
|
||||
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional event-form fields shown by default (the rest hide behind
|
||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||
@@ -457,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 `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
|
||||
|
||||
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Outcome of the last mirror sync, for the settings status line. */
|
||||
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||
SpecialDatesStatus(
|
||||
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||
store.edit { prefs ->
|
||||
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||
}
|
||||
}
|
||||
|
||||
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||
val specialDatesLastForegroundSync: Flow<Long> =
|
||||
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||
|
||||
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||
}
|
||||
|
||||
private fun typeEnabledKey(type: SpecialDateType) =
|
||||
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||
|
||||
private fun calendarIdKey(type: SpecialDateType) =
|
||||
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -464,40 +524,6 @@ class SettingsPrefs @Inject constructor(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/** Parse a plain comma-joined view order, completed to every implemented view. */
|
||||
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||
completeViewOrder(
|
||||
stored?.split(',').orEmpty()
|
||||
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||
)
|
||||
|
||||
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
|
||||
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||
if (stored == null) return QuickSwitchConfig.Default
|
||||
val parsed = stored.split(',').mapNotNull { raw ->
|
||||
val token = raw.trim()
|
||||
val disabled = token.startsWith("!")
|
||||
val name = if (disabled) token.drop(1) else token
|
||||
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||
}
|
||||
val order = completeViewOrder(parsed.map { it.first })
|
||||
// Only views explicitly stored disabled are excluded; anything appended
|
||||
// (a view added in a later release) defaults into the cycle.
|
||||
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
|
||||
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
|
||||
}
|
||||
|
||||
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
|
||||
completeViewOrder(config.order).joinToString(",") { view ->
|
||||
if (view in config.enabled) view.name else "!${view.name}"
|
||||
}
|
||||
|
||||
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||
val ordered = seen.distinct()
|
||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
@@ -510,8 +536,6 @@ class SettingsPrefs @Inject constructor(
|
||||
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 QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
@@ -545,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_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
|
||||
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||
longPreferencesKey("special_dates_last_foreground_sync")
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||
data class SpecialDatesStatus(
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long,
|
||||
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||
val stalled: SpecialDatesStalledReason?,
|
||||
)
|
||||
|
||||
/** Why the special-dates mirror is paused. */
|
||||
enum class SpecialDatesStalledReason {
|
||||
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||
PermissionRevoked,
|
||||
}
|
||||
|
||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||
data class BackupStatus(
|
||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
*
|
||||
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||
* space, as before, left near-identical painted swatches and stranded the
|
||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||
*
|
||||
* Three steps, all in painted space:
|
||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||
* hue, and all the neutrals together.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||
* colour already kept. Small palettes are already curated by their adapter
|
||||
* and pass through whole.
|
||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||
* seam lands in dead space and no hue family is torn across both ends.
|
||||
*
|
||||
* Every surviving option keeps its provider [EventColorOption.key], so a pick
|
||||
* still round-trips through sync.
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { pastelArgb(it.argb) }
|
||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||
}
|
||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders swatches continuously around the (painted) hue wheel, then cuts the
|
||||
* circle at its widest angular gap so the single seam lands in empty space
|
||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
val byHue = swatches.sortedWith(
|
||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||
)
|
||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||
var cutAfter = byHue.lastIndex
|
||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||
for (i in 0 until byHue.lastIndex) {
|
||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||
if (gap > widestGap) {
|
||||
widestGap = gap
|
||||
cutAfter = i
|
||||
}
|
||||
}
|
||||
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
|
||||
}
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
for (candidate in byVividness) {
|
||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* The softening the colour picker paints over every swatch (mirrored by
|
||||
* ui/pastelize, which wraps this): keep the hue, scale and clamp saturation
|
||||
* into a gentle band, and pin value to a constant so nothing screams and
|
||||
* everything reads on the surface. Value is fixed here so curation is
|
||||
* theme-independent — only hue and saturation distinguish painted swatches.
|
||||
*/
|
||||
fun pastelArgb(rawArgb: Int): Int {
|
||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||
val b = (rawArgb and 0xFF) / 255f
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
val hue = when {
|
||||
delta == 0f -> 0f
|
||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}.let { if (it < 0f) it + 360f else it }
|
||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||
val s = sat.coerceIn(0.25f, 0.65f)
|
||||
val v = PASTEL_VALUE
|
||||
val c = v * s
|
||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||
val m = v - c
|
||||
val (rr, gg, bb) = when {
|
||||
hue < 60f -> Triple(c, x, 0f)
|
||||
hue < 120f -> Triple(x, c, 0f)
|
||||
hue < 180f -> Triple(0f, c, x)
|
||||
hue < 240f -> Triple(0f, x, c)
|
||||
hue < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||
}
|
||||
|
||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||
private const val PASTEL_VALUE = 0.82f
|
||||
|
||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||
private const val CURATION_TRIGGER_SIZE = 36
|
||||
|
||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||
private const val MIN_DELTA_E = 13.0
|
||||
|
||||
/**
|
||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||
* is dropped rather than shown as pale filler.
|
||||
*/
|
||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||
|
||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||
val chroma: Double get() = hypot(a, b)
|
||||
|
||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||
|
||||
fun deltaE(other: Lab): Double =
|
||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||
|
||||
companion object {
|
||||
fun of(argb: Int): Lab {
|
||||
fun linear(shift: Int): Double {
|
||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
}
|
||||
val r = linear(16)
|
||||
val g = linear(8)
|
||||
val b = linear(0)
|
||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||
val fy = f(y)
|
||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,37 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||
* contact date. Stable across syncs (the reconciliation key), and namespaced by
|
||||
* type so the same contact's birthday and anniversary never collide.
|
||||
*/
|
||||
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)
|
||||
|
||||
/**
|
||||
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||
* "Jane's birthday" when no year is known.
|
||||
*/
|
||||
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||
template
|
||||
.replace("{name}", name)
|
||||
.replace("{year}", year?.toString().orEmpty())
|
||||
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||
.replace(Regex("""\(\s*\)"""), "")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
@@ -79,11 +79,6 @@ fun CalendarHost(
|
||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||
|
||||
// View customisation (#24): the quick-switch cycle and drawer order. Both have
|
||||
// sensible non-empty initial values, so they're ready before the first frame.
|
||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
@@ -217,17 +212,6 @@ fun CalendarHost(
|
||||
detailKey = key
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenView -> {
|
||||
// A widget header tap: land on a top-level view with no date
|
||||
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||
// the stack on the target (null → the default home) over the
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
@@ -276,8 +260,6 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -287,8 +269,6 @@ fun CalendarHost(
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
@@ -297,8 +277,6 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
@@ -307,8 +285,6 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -28,21 +26,4 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||
.map { it.cycle }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -31,13 +31,4 @@ sealed interface WidgetNavRequest {
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||
|
||||
/**
|
||||
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||
* issue #18), so backing out returns to the default view, then exits.
|
||||
*/
|
||||
data class OpenView(val view: CalendarView?) : WidgetNavRequest
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ 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.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
@@ -97,8 +96,6 @@ fun AgendaScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -123,7 +120,6 @@ fun AgendaScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -144,7 +140,7 @@ fun AgendaScreen(
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -15,21 +15,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.pastelArgb
|
||||
|
||||
/**
|
||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||
* - Keeps the hue (so users still recognise their calendars)
|
||||
* - Caps saturation so harsh provider colors stop screaming
|
||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||
*
|
||||
* The hue/saturation shaping lives in [pastelArgb] so the event-colour picker's
|
||||
* curation reasons about the exact colour painted here (#22); this only picks
|
||||
* the theme's brightness.
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(pastelArgb(rawArgb), hsv)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ fun CalendarDrawer(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -74,10 +73,10 @@ fun CalendarDrawer(
|
||||
DrawerHeader()
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||
viewOrder.forEachIndexed { index, view ->
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = positionOf(index, viewOrder.size),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
|
||||
@@ -45,39 +45,11 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||
if (available.isEmpty()) return this
|
||||
val i = available.indexOf(this)
|
||||
if (i < 0) return available.first()
|
||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||
return available[(i + 1) % available.size]
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's customisation of the top-bar quick-switch button (#24): which views
|
||||
* it cycles through ([enabled]) and in what [order]. [order] always lists every
|
||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||
* is the subset the pill actually steps through, in [order]. The navigation
|
||||
* drawer keeps its own separate order and always lists every view, so a view
|
||||
* disabled here stays reachable there.
|
||||
*/
|
||||
data class QuickSwitchConfig(
|
||||
val order: List<CalendarView>,
|
||||
val enabled: Set<CalendarView>,
|
||||
) {
|
||||
/** Views the pill steps through, in [order]. */
|
||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||
|
||||
companion object {
|
||||
/** All views, in default order, all enabled. */
|
||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||
|
||||
/**
|
||||
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||
* least two targets, so the settings screen blocks disabling below this.
|
||||
*/
|
||||
const val MIN_ENABLED = 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level view back stack (bottom → top): the [default] home view always
|
||||
* sits at the bottom. Pressing back pops one level until only the home view
|
||||
|
||||
@@ -142,9 +142,6 @@ fun GroupedRow(
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
||||
// list, which owns uniform spacing itself so every slot has the same pitch.
|
||||
gapBelow: Boolean = true,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
@@ -163,10 +160,9 @@ fun GroupedRow(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
val gap = when {
|
||||
!gapBelow -> Modifier
|
||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
else -> Modifier
|
||||
val gap = when (position) {
|
||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
Position.Bottom, Position.Alone -> Modifier
|
||||
}
|
||||
val itemColors = if (selected) {
|
||||
ListItemDefaults.colors(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||
val ReorderableRowHeight: Dp = 64.dp
|
||||
private val RowGap: Dp = 2.dp
|
||||
|
||||
/**
|
||||
* A vertical list whose rows can be dragged into a new order by their handle.
|
||||
*
|
||||
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
||||
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||
* been dragged. The held row follows the finger while the others slide out of
|
||||
* its way (animated); on release the held row settles into its slot, then the
|
||||
* order is committed with a single [onReorder] call.
|
||||
*
|
||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ReorderableColumn(
|
||||
items: List<T>,
|
||||
keyOf: (T) -> Any,
|
||||
onReorder: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||
) {
|
||||
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Local working copy; re-seeded when the incoming list changes (including the
|
||||
// echo of our own committed order).
|
||||
var order by remember(items) { mutableStateOf(items) }
|
||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||
// Live translation of the held row from its slot (px); also drives the
|
||||
// release settle, animated back onto a slot before the order is committed.
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
// Whole slots dragged → the slot the held row currently hovers over.
|
||||
val targetIndex = draggedIndex?.let {
|
||||
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
}
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||
order.forEachIndexed { index, item ->
|
||||
val key = keyOf(item)
|
||||
val isDragged = key == draggedKey
|
||||
|
||||
// Slide neighbours by one pitch to open the gap the held row will drop
|
||||
// into. Snap (not animate) once idle, so committing the new order — which
|
||||
// moves each row's slot — doesn't visibly fight a lingering animation.
|
||||
val shift = when {
|
||||
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
||||
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
||||
index in targetIndex until draggedIndex -> pitchPx
|
||||
else -> 0f
|
||||
}
|
||||
val animatedShift by animateFloatAsState(
|
||||
targetValue = shift,
|
||||
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
||||
label = "reorderShift",
|
||||
)
|
||||
|
||||
val dragHandle = Modifier.pointerInput(key) {
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
settleJob?.cancel()
|
||||
draggedKey = key
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDrag = { change, amount ->
|
||||
change.consume()
|
||||
dragOffset += amount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
val from = order.indexOfFirst { keyOf(it) == key }
|
||||
if (from < 0) return@detectDragGestures
|
||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
settleJob = scope.launch {
|
||||
// Settle the held row onto its target slot, then commit —
|
||||
// resetting offset and slot in the same frame, so nothing jumps.
|
||||
Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) {
|
||||
dragOffset = value
|
||||
}
|
||||
if (to != from) {
|
||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||
onReorder(order)
|
||||
}
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
settleJob = scope.launch {
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.height(ReorderableRowHeight)
|
||||
.zIndex(if (isDragged) 1f else 0f)
|
||||
.graphicsLayer {
|
||||
translationY = if (isDragged) dragOffset else animatedShift
|
||||
if (isDragged) {
|
||||
scaleX = 1.02f
|
||||
scaleY = 1.02f
|
||||
shadowElevation = 8.dp.toPx()
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
clip = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,6 @@ 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.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
@@ -117,8 +116,6 @@ fun DayScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
@@ -178,7 +175,6 @@ fun DayScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -200,7 +196,7 @@ fun DayScreen(
|
||||
DayTopBar(
|
||||
date = date,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -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,
|
||||
@@ -1712,20 +1734,12 @@ private fun ColorPickerDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (palette.isNotEmpty()) {
|
||||
// The event's current colour may not be in the curated
|
||||
// palette (thinned near-duplicate, or a raw colour set
|
||||
// elsewhere) — append it so the selection ring has a home.
|
||||
val swatches = palette.map { it.argb }.let {
|
||||
if (selected != null && selected !in it) it + selected else it
|
||||
}
|
||||
ColorSwatchRow(
|
||||
colors = swatches,
|
||||
colors = palette.map { it.argb },
|
||||
selected = selected,
|
||||
onSelect = { argb ->
|
||||
val option = palette.firstOrNull { it.argb == argb }
|
||||
// The appended current colour has no key to write —
|
||||
// it is already the event's colour, so just close.
|
||||
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
|
||||
palette.firstOrNull { it.argb == argb }
|
||||
?.let { onPickKey(it.key, it.argb) }
|
||||
},
|
||||
dark = dark,
|
||||
)
|
||||
@@ -1866,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()
|
||||
@@ -1879,6 +1894,7 @@ private fun InlineField(
|
||||
textStyle = textStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
)
|
||||
}
|
||||
@@ -1893,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)
|
||||
@@ -1902,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,
|
||||
@@ -1923,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) {
|
||||
@@ -1932,7 +1950,7 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onTimeClick)
|
||||
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
* True when the event's calendar is a contact special-dates calendar the app
|
||||
* manages. While editing such an event the title, date and recurrence are
|
||||
* locked (sync overwrites them); reminders, location and notes stay editable.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/**
|
||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||
|
||||
@@ -165,7 +165,8 @@ class EventEditViewModel @Inject constructor(
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
) { local, external, palette ->
|
||||
settingsPrefs.managedCalendarIds,
|
||||
) { local, external, palette, managedIds ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
@@ -180,6 +181,8 @@ class EventEditViewModel @Inject constructor(
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
// Editing an event in a managed calendar locks its synced fields.
|
||||
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
|
||||
@@ -68,7 +68,6 @@ 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.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
@@ -99,8 +98,6 @@ fun MonthScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -160,7 +157,6 @@ fun MonthScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(month.year, month.month, 1),
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -182,7 +178,7 @@ fun MonthScreen(
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -37,12 +37,12 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
@@ -93,6 +93,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||
@@ -101,14 +102,9 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||
@@ -128,7 +124,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, Views, EventForm, Notifications }
|
||||
private enum class SettingsSection { Appearance, EventForm, Notifications, SpecialDates }
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||
@@ -173,13 +169,6 @@ fun SettingsScreen(
|
||||
) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Views,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.EventForm,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
@@ -192,7 +181,19 @@ fun SettingsScreen(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
NotificationsScreen(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onBack = { section = null },
|
||||
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.SpecialDates,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,13 +219,6 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
||||
onClick = { onOpenSection(SettingsSection.Appearance) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
summary = stringResource(R.string.settings_views_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
|
||||
onClick = { onOpenSection(SettingsSection.Views) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
summary = stringResource(R.string.settings_event_form_subtitle),
|
||||
@@ -246,6 +240,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
|
||||
@@ -685,131 +686,6 @@ private fun AppearanceScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Views (#24): reorder the top-bar quick-switch cycle and choose which views it
|
||||
* steps through, plus reorder the navigation-drawer list. Two independent lists —
|
||||
* a view disabled in the quick-switch cycle is still reachable from the drawer,
|
||||
* which always lists every view. The switch needs at least two targets, so the
|
||||
* last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
|
||||
*/
|
||||
@Composable
|
||||
private fun ViewsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
onBack = onBack,
|
||||
) {
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Turning a view off is blocked once only the minimum remain enabled.
|
||||
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||
ReorderableColumn(
|
||||
items = config.order,
|
||||
keyOf = { it },
|
||||
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
|
||||
) { view, position, dragHandle, isDragging ->
|
||||
val checked = view in config.enabled
|
||||
ViewRow(
|
||||
view = view,
|
||||
position = position,
|
||||
isDragging = isDragging,
|
||||
dragHandle = dragHandle,
|
||||
dimmed = !checked,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
// Keep the last two on: with fewer, the pill can't switch.
|
||||
enabled = !checked || canDisable,
|
||||
onCheckedChange = { on ->
|
||||
val enabled = if (on) config.enabled + view else config.enabled - view
|
||||
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionHeader(stringResource(R.string.settings_drawer_order_header))
|
||||
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReorderableColumn(
|
||||
items = state.drawerViewOrder,
|
||||
keyOf = { it },
|
||||
onReorder = { viewModel.setDrawerViewOrder(it) },
|
||||
) { view, position, dragHandle, isDragging ->
|
||||
ViewRow(
|
||||
view = view,
|
||||
position = position,
|
||||
isDragging = isDragging,
|
||||
dragHandle = dragHandle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
|
||||
@Composable
|
||||
private fun ViewRow(
|
||||
view: CalendarView,
|
||||
position: Position,
|
||||
isDragging: Boolean,
|
||||
dragHandle: Modifier,
|
||||
dimmed: Boolean = false,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = position,
|
||||
dimmed = dimmed,
|
||||
minHeight = ReorderableRowHeight,
|
||||
// The reorderable column owns the inter-row spacing (uniform pitch).
|
||||
gapBelow = false,
|
||||
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = view.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
trailing?.invoke()
|
||||
if (trailing != null) Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = dragHandle.size(48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = stringResource(R.string.reorder_drag_handle),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
||||
@Composable
|
||||
private fun SettingsHint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventFormScreen(
|
||||
state: SettingsUiState,
|
||||
@@ -925,6 +801,7 @@ private fun NotificationsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
@@ -1049,6 +926,25 @@ private fun NotificationsScreen(
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// A contact special-dates calendar owns its reminders in
|
||||
// its own section — link there instead of an override.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
// Calendar card; tapping expands it into a grouped list
|
||||
// of three (the card + the timed and all-day rows).
|
||||
@@ -1171,6 +1067,282 @@ 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) }
|
||||
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
onBack = onBack,
|
||||
) {
|
||||
// Paused banner: the permission was revoked after enabling.
|
||||
if (state.enabled && state.stalledPermission) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_paused_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_paused_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
|
||||
) { Text(stringResource(R.string.settings_special_dates_grant)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_enable),
|
||||
summary = stringResource(R.string.settings_special_dates_enable_hint),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.enabled,
|
||||
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
|
||||
)
|
||||
},
|
||||
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
|
||||
)
|
||||
|
||||
if (state.enabled) {
|
||||
// Per-type card: the toggle, and while on, its title format and its
|
||||
// calendar-wide reminder.
|
||||
SpecialDateType.entries.forEachIndexed { index, type ->
|
||||
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
|
||||
val on = type in state.types
|
||||
GroupedRow(
|
||||
title = stringResource(specialDateTypeLabel(type)),
|
||||
position = if (on) Position.Top else Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = on,
|
||||
onCheckedChange = { want ->
|
||||
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
|
||||
else confirmDisableType = type
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
|
||||
},
|
||||
)
|
||||
if (on) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_template),
|
||||
summary = state.titleTemplates[type].orEmpty(),
|
||||
position = Position.Middle,
|
||||
onClick = { editTemplate = type },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_reminders),
|
||||
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
|
||||
position = Position.Bottom,
|
||||
onClick = { reminderPickerType = type },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_show_year),
|
||||
summary = stringResource(R.string.settings_special_dates_show_year_hint),
|
||||
position = Position.Top,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showYear,
|
||||
onCheckedChange = viewModel::setSpecialDatesShowYear,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_sync_now),
|
||||
summary = specialDatesLastRunLabel(context, state.lastRun),
|
||||
position = Position.Bottom,
|
||||
onClick = viewModel::syncSpecialDatesNow,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_calendar_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmDisableAll) {
|
||||
SpecialDatesDisableDialog(
|
||||
message = stringResource(R.string.settings_special_dates_disable_all_message),
|
||||
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
|
||||
onDismiss = { confirmDisableAll = false },
|
||||
)
|
||||
}
|
||||
confirmDisableType?.let { type ->
|
||||
SpecialDatesDisableDialog(
|
||||
message = stringResource(
|
||||
R.string.settings_special_dates_disable_type_message,
|
||||
stringResource(specialDateTypeLabel(type)),
|
||||
),
|
||||
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
|
||||
onDismiss = { confirmDisableType = null },
|
||||
)
|
||||
}
|
||||
editTemplate?.let { type ->
|
||||
SpecialDatesTemplateDialog(
|
||||
initial = state.titleTemplates[type].orEmpty(),
|
||||
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
|
||||
onDismiss = { editTemplate = null },
|
||||
)
|
||||
}
|
||||
reminderPickerType?.let { type ->
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_special_dates_reminders),
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
|
||||
// Managed calendars own their reminders outright — no "inherit global".
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||
onDismiss = { reminderPickerType = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
|
||||
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesDisableDialog(
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesTemplateDialog(
|
||||
initial: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var text by rememberSaveable { mutableStateOf(initial) }
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||
text = {
|
||||
Column {
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
@@ -7,10 +8,9 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -39,10 +39,6 @@ data class SettingsUiState(
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
@@ -75,4 +71,30 @@ data class SettingsUiState(
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
*/
|
||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||
/**
|
||||
* Ids of the contact special-dates calendars. Their reminders are owned by
|
||||
* the Contact special dates section, so the per-calendar override list links
|
||||
* out to it instead of offering an override here.
|
||||
*/
|
||||
val managedCalendarIds: Set<Long> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* State for the contact special-dates sub-page (issue #15). Exposed as its own
|
||||
* flow rather than folded into [SettingsUiState] (whose combine is already full).
|
||||
* [titleTemplates] is always populated — a blank stored template resolves to the
|
||||
* localized default so the editor never shows an empty field.
|
||||
*/
|
||||
data class SpecialDatesUiState(
|
||||
val enabled: Boolean = false,
|
||||
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
||||
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||
val showYear: Boolean = true,
|
||||
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||
val stalledPermission: Boolean = false,
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long = 0L,
|
||||
)
|
||||
|
||||
@@ -10,18 +10,25 @@ 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.ui.common.QuickSwitchConfig
|
||||
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
|
||||
@@ -42,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() {
|
||||
|
||||
@@ -87,8 +97,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.perCalendarReminderOverride,
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
writableCalendars,
|
||||
) { overrides, allDayOverrides, calendars ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||
prefs.managedCalendarIds,
|
||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||
},
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
@@ -104,14 +115,8 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.autofocusEventTitle,
|
||||
prefs.pastEventDisplay,
|
||||
prefs.dimCompletedEvents,
|
||||
// View customisation (#24) folded into one flow so it fits this
|
||||
// group — the outer combine is already at its five-arg limit.
|
||||
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
||||
ViewCustomization(quickSwitch, drawer)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
},
|
||||
::MiscSettings,
|
||||
),
|
||||
) { base, defaults, overrides, views, misc ->
|
||||
base.copy(
|
||||
defaultView = views.defaultView,
|
||||
@@ -123,8 +128,6 @@ class SettingsViewModel @Inject constructor(
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -133,6 +136,7 @@ class SettingsViewModel @Inject constructor(
|
||||
perCalendarReminderOverride = overrides.timed,
|
||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||
writableCalendars = overrides.calendars,
|
||||
managedCalendarIds = overrides.managedIds,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -158,6 +162,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val timed: Map<Long, List<Int>>,
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
val managedIds: Set<Long>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
@@ -173,14 +178,102 @@ class SettingsViewModel @Inject constructor(
|
||||
val autofocusEventTitle: Boolean,
|
||||
val pastEventDisplay: PastEventDisplay,
|
||||
val dimCompletedEvents: Boolean,
|
||||
val viewCustomization: ViewCustomization,
|
||||
)
|
||||
|
||||
private data class ViewCustomization(
|
||||
val quickSwitch: QuickSwitchConfig,
|
||||
val drawerOrder: List<CalendarView>,
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
|
||||
combine(
|
||||
prefs.specialDatesEnabled,
|
||||
prefs.specialDatesTypes,
|
||||
prefs.specialDatesTitleTemplates,
|
||||
prefs.specialDatesShowYear,
|
||||
prefs.specialDatesStatus,
|
||||
) { enabled, types, templates, showYear, status ->
|
||||
SpecialDatesUiState(
|
||||
enabled = enabled,
|
||||
types = types,
|
||||
// A blank stored template resolves to the localized default so the
|
||||
// editor always shows something sensible.
|
||||
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||
templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: specialDatesSpec.defaultTitleTemplate(type)
|
||||
},
|
||||
showYear = showYear,
|
||||
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||
lastRun = status.lastRun,
|
||||
)
|
||||
},
|
||||
prefs.specialDatesCalendars,
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
) { base, calendars, allDayOverrides ->
|
||||
base.copy(
|
||||
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||
// An override is always seeded on creation; present-empty = None.
|
||||
when (val minutes = allDayOverrides[calendarId]) {
|
||||
null -> CalendarReminderOverride.Inherit
|
||||
emptyList<Int>() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(minutes)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SpecialDatesUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Turn the mirror on or off. Enabling schedules the daily job and runs an
|
||||
* immediate sync (which creates the calendars); disabling cancels the job and
|
||||
* tears the managed calendars down. The caller must have READ_CONTACTS granted
|
||||
* before enabling.
|
||||
*/
|
||||
fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesEnabled(enabled)
|
||||
if (enabled) {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = true)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
} else {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = false)
|
||||
withContext(io) { specialDatesEngine.teardown() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDateTypeEnabled(type, enabled)
|
||||
// Reconcile now: create the new type's calendar, or delete a disabled one.
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesTitleTemplate(type, template.trim())
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesShowYear(enabled)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a type's managed-calendar reminder default and apply it to all its
|
||||
* existing events (managed calendars own their reminders calendar-wide).
|
||||
*/
|
||||
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||
}
|
||||
|
||||
/** Kick off an immediate reconcile (the "Sync now" action). */
|
||||
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -262,14 +355,6 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||
}
|
||||
|
||||
fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||
viewModelScope.launch { prefs.setQuickSwitchConfig(config) }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ 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.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
@@ -132,8 +131,6 @@ fun WeekScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -198,7 +195,6 @@ fun WeekScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = weekStart,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -220,7 +216,7 @@ fun WeekScreen(
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -193,8 +193,6 @@ private fun AgendaHeader() {
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Tap the "Upcoming" title to open the app on the user's default view
|
||||
// (issue #20) — a null target resolves to the default home in the host.
|
||||
Text(
|
||||
text = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
@@ -202,11 +200,7 @@ private fun AgendaHeader() {
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
|
||||
@@ -194,8 +194,6 @@ private fun MonthHeader(label: String) {
|
||||
),
|
||||
),
|
||||
)
|
||||
// Tap the month/year title to open the app on the month view (issue #18),
|
||||
// rooted over the default home so backing out returns there.
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
@@ -206,17 +204,16 @@ private fun MonthHeader(label: String) {
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||
),
|
||||
),
|
||||
.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
// The "today" button snaps the grid back to the current month in place.
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||
),
|
||||
),
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
@@ -304,15 +301,6 @@ private fun WeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
||||
* on a day opens it; only an event bar on top opts out to open its own detail.
|
||||
*/
|
||||
private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
@@ -320,7 +308,11 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
// Tap a day number to open that day, rooted in the month view so back
|
||||
// returns to the month grid.
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
@@ -347,7 +339,6 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
@@ -361,14 +352,7 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, width = colW)
|
||||
} else {
|
||||
// Empty lane cell: a tap opens that day, so blank space in a
|
||||
// day column is a day-open target just like the number is.
|
||||
Box(
|
||||
GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, week.days[col])),
|
||||
) {}
|
||||
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
||||
}
|
||||
col += 1
|
||||
}
|
||||
@@ -417,20 +401,14 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||
|
||||
@Composable
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||
// The overflow row is part of the day column too: tapping it (whether
|
||||
// it shows "+N" or is blank) opens that day, same as the app.
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (hidden > 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Ein moderner Kalender.</string>
|
||||
|
||||
<string name="state_loading">Lädt…</string>
|
||||
<string name="state_retry">Erneut versuchen</string>
|
||||
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
|
||||
@@ -10,9 +10,10 @@
|
||||
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
||||
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App 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>
|
||||
@@ -25,6 +26,7 @@
|
||||
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
||||
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
||||
|
||||
<!-- Monatsansicht (S1) -->
|
||||
<string name="month_prev">Vorheriger Monat</string>
|
||||
<string name="month_next">Nächster Monat</string>
|
||||
@@ -33,11 +35,14 @@
|
||||
<string name="month_open_menu">Menü öffnen</string>
|
||||
<string name="month_action_settings">Einstellungen</string>
|
||||
<string name="month_a11y_today_prefix">Heute</string>
|
||||
|
||||
<!-- Wochenansicht (S2) -->
|
||||
<string name="week_today_action">Diese Woche</string>
|
||||
<string name="week_number_label">KW</string>
|
||||
|
||||
<!-- Tagesansicht (S3) -->
|
||||
<string name="day_today_action">Heute</string>
|
||||
|
||||
<!-- Event-Detail-Screen (S4) -->
|
||||
<string name="event_detail_back">Zurück</string>
|
||||
<string name="event_detail_edit">Bearbeiten</string>
|
||||
@@ -56,6 +61,7 @@
|
||||
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||
<string name="dialog_cancel">Abbrechen</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
|
||||
<!-- Termin-Formular (v1.2 Erstellen) -->
|
||||
<string name="event_edit_new_title">Neuer Termin</string>
|
||||
<string name="event_edit_close">Schließen</string>
|
||||
@@ -88,6 +94,7 @@
|
||||
<string name="reminder_unit_weeks">Wochen</string>
|
||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||
|
||||
<!-- Termin-Formular — eigene Terminfarbe -->
|
||||
<string name="event_edit_color">Farbe</string>
|
||||
<string name="event_edit_color_default">Kalenderfarbe</string>
|
||||
@@ -96,6 +103,7 @@
|
||||
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
||||
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
||||
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
||||
|
||||
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||
@@ -105,6 +113,7 @@
|
||||
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
|
||||
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
|
||||
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
|
||||
|
||||
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||
@@ -147,6 +156,7 @@
|
||||
<string name="event_attendee_tentative">Vorläufig</string>
|
||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
|
||||
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||
<string name="event_detail_reminders">Erinnerungen</string>
|
||||
<string name="event_detail_timezone">Zeitzone</string>
|
||||
@@ -186,8 +196,10 @@
|
||||
<item quantity="one">%d Stunde</item>
|
||||
<item quantity="other">%d Stunden</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Geteilte Event-Strings -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
|
||||
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
||||
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
||||
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
||||
@@ -203,19 +215,23 @@
|
||||
<string name="reminder_onboarding_skip_button">Später</string>
|
||||
<string name="reminder_action_snooze">Schlummern</string>
|
||||
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||
|
||||
<!-- View-Switcher (M1) -->
|
||||
<string name="view_month">Monat</string>
|
||||
<string name="view_week">Woche</string>
|
||||
<string name="view_day">Tag</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Ansicht</string>
|
||||
|
||||
<!-- Zu Datum springen (Navigationsleiste) -->
|
||||
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
||||
|
||||
<!-- Agenda-Ansicht -->
|
||||
<string name="agenda_today_action">Heute</string>
|
||||
<string name="agenda_header_today">Heute</string>
|
||||
<string name="agenda_header_tomorrow">Morgen</string>
|
||||
<string name="agenda_empty_title">Alles erledigt</string>
|
||||
|
||||
<!-- Terminsuche -->
|
||||
<string name="search_action">Suchen</string>
|
||||
<string name="search_hint">Termine suchen</string>
|
||||
@@ -223,6 +239,7 @@
|
||||
<string name="search_clear">Löschen</string>
|
||||
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||
|
||||
<!-- Startbildschirm-Widgets -->
|
||||
<string name="widget_agenda_title">Anstehend</string>
|
||||
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||
@@ -233,15 +250,19 @@
|
||||
<string name="widget_prev_month">Vorheriger Monat</string>
|
||||
<string name="widget_next_month">Nächster Monat</string>
|
||||
<string name="widget_today">Heute</string>
|
||||
|
||||
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||
|
||||
<!-- Schnelleinstellungen-Kachel -->
|
||||
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
||||
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
||||
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
||||
|
||||
<!-- Kalender-Filter (M3) -->
|
||||
<string name="filter_title">Kalender</string>
|
||||
|
||||
<!-- Einstellungen (M4) -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_back">Zurück</string>
|
||||
@@ -322,6 +343,7 @@
|
||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||
<string name="settings_report_problem">Problem melden</string>
|
||||
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
||||
|
||||
<!-- Calendar manager -->
|
||||
<string name="calendars_title">Kalender</string>
|
||||
<string name="calendars_local_header">Deine Kalender</string>
|
||||
@@ -376,6 +398,7 @@
|
||||
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
||||
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
||||
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||
@@ -387,4 +410,38 @@
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||
<string name="special_dates_calendar_custom">Besondere Tage</string>
|
||||
<string name="special_dates_default_title_birthday">{name}s Geburtstag ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string>
|
||||
<string name="settings_special_dates_subtitle">Geburtstage & Jahrestage aus Kontakten</string>
|
||||
<string name="settings_section_special_dates">Besondere Kontakttage</string>
|
||||
<string name="settings_special_dates_enable">Kontakttage anzeigen</string>
|
||||
<string name="settings_special_dates_enable_hint">Spiegelt Geburtstage und weitere Daten deiner Kontakte in lokale Kalender. Liest Kontakte nur auf diesem Gerät — nichts wird hochgeladen, und deine Kontakte werden nie verändert.</string>
|
||||
<string name="settings_special_dates_type_birthday">Geburtstage</string>
|
||||
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
|
||||
<string name="settings_special_dates_type_custom">Weitere Tage</string>
|
||||
<string name="settings_special_dates_template">Titelformat</string>
|
||||
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt).</string>
|
||||
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
|
||||
<string name="settings_special_dates_show_year_hint">{year} in Titeln anzeigen, wenn es bekannt ist</string>
|
||||
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
|
||||
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
|
||||
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">In Besondere Kontakttage festgelegt</string>
|
||||
<string name="settings_special_dates_paused_title">Pausiert</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
|
||||
<string name="settings_special_dates_grant">Zugriff erlauben</string>
|
||||
<string name="settings_special_dates_disable_title">Kontakttage deaktivieren?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||
<string name="dialog_save">Speichern</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</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>
|
||||
@@ -308,12 +310,6 @@
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Views</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||
<string name="settings_section_event_form">New event form</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||
@@ -348,9 +344,37 @@
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="settings_section_special_dates">Contact special dates</string>
|
||||
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
|
||||
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||
<string name="settings_special_dates_template">Title format</string>
|
||||
<string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
|
||||
<string name="settings_special_dates_reminders">Reminders</string>
|
||||
<string name="settings_special_dates_show_year">Show year</string>
|
||||
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
|
||||
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
|
||||
<string name="settings_special_dates_paused_title">Paused</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||
<string name="settings_special_dates_grant">Grant access</string>
|
||||
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||
<string name="dialog_save">Save</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -469,4 +493,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 ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun deleteCalendar(id: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedCalendarIds += id
|
||||
managedCalendarRows.removeAll { it.id == id }
|
||||
managedEvents.remove(id)
|
||||
}
|
||||
|
||||
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||
@@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedEventIds += eventId
|
||||
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
|
||||
}
|
||||
|
||||
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
|
||||
|
||||
var nextManagedEventId: Long = 1_000L
|
||||
private var managedCalendarSeq: Long = 700L
|
||||
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
|
||||
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
|
||||
|
||||
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
|
||||
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
|
||||
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
|
||||
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
|
||||
|
||||
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
|
||||
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
|
||||
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||
managedEvents.getOrPut(id) { mutableListOf() }
|
||||
}
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
writeError?.let { throw it }
|
||||
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
|
||||
val id = managedCalendarSeq++
|
||||
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||
managedEvents.getOrPut(id) { mutableListOf() }
|
||||
return id
|
||||
}
|
||||
|
||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
|
||||
|
||||
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
|
||||
managedEvents[calendarId]?.toList() ?: emptyList()
|
||||
|
||||
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||
writeError?.let { throw it }
|
||||
insertedManagedEvents += form to uid
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
val id = nextManagedEventId++
|
||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
|
||||
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
|
||||
return id
|
||||
}
|
||||
|
||||
/** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
|
||||
val appliedManagedReminders = mutableListOf<Triple<Long, Int, List<Int>>>()
|
||||
|
||||
override fun applyManagedCalendarReminders(
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
minutes: List<Int>,
|
||||
) {
|
||||
writeError?.let { throw it }
|
||||
appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
|
||||
}
|
||||
|
||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||
writeError?.let { throw it }
|
||||
if (values.isEmpty()) return
|
||||
updatedManagedFields += eventId to values
|
||||
managedEvents.values.forEach { rows ->
|
||||
val idx = rows.indexOfFirst { it.eventId == eventId }
|
||||
if (idx >= 0) {
|
||||
var row = rows[idx]
|
||||
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
|
||||
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
|
||||
if (values.containsKey(CalendarContract.Events.RRULE)) {
|
||||
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
|
||||
}
|
||||
rows[idx] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class SpecialDatesSyncEngineTest {
|
||||
|
||||
private class FakeContacts(
|
||||
var permission: Boolean = true,
|
||||
var dates: List<ContactSpecialDate> = emptyList(),
|
||||
) : ContactSpecialDatesDataSource {
|
||||
override fun hasPermission() = permission
|
||||
override fun readSpecialDates() = dates
|
||||
}
|
||||
|
||||
private class FakeSpec : SpecialDatesCalendarSpec {
|
||||
override fun displayName(type: SpecialDateType) = type.name
|
||||
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
|
||||
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
|
||||
SpecialDateType.Birthday -> "{name}'s birthday ({year})"
|
||||
SpecialDateType.Anniversary -> "{name}'s anniversary"
|
||||
SpecialDateType.Custom -> "{name}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefs(tempDir: Path): SettingsPrefs {
|
||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||
)
|
||||
return SettingsPrefs(store)
|
||||
}
|
||||
|
||||
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
|
||||
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
|
||||
|
||||
private suspend fun engineWith(
|
||||
tempDir: Path,
|
||||
contacts: FakeContacts,
|
||||
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
|
||||
enabled: Boolean = true,
|
||||
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
|
||||
val settings = prefs(tempDir)
|
||||
if (enabled) settings.setSpecialDatesEnabled(true)
|
||||
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
|
||||
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled)
|
||||
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
|
||||
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
|
||||
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(
|
||||
dates = listOf(
|
||||
birthday("a", "Jane", 5, 14, 1990),
|
||||
birthday("b", "Bob", 12, 25, null),
|
||||
),
|
||||
)
|
||||
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||
|
||||
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Success)
|
||||
// All three type calendars exist; the birthday one holds both contacts.
|
||||
assertThat(calendars.createdManagedCalendars.map { it.type })
|
||||
.containsExactlyElementsIn(SpecialDateType.entries)
|
||||
assertThat(calendars.insertedManagedEvents).hasSize(2)
|
||||
assertThat(calendars.insertedManagedEvents.map { it.second })
|
||||
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
|
||||
// Year-less contact drops the year; year-known keeps it.
|
||||
val titles = calendars.insertedManagedEvents.map { it.first.title }
|
||||
assertThat(titles).contains("Jane's birthday (1990)")
|
||||
assertThat(titles).contains("Bob's birthday")
|
||||
// Calendar ids were persisted for the editor / next sync.
|
||||
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||
|
||||
engine.sync()
|
||||
val insertsAfterFirst = calendars.insertedManagedEvents.size
|
||||
val calendarsAfterFirst = calendars.createdManagedCalendars.size
|
||||
engine.sync()
|
||||
|
||||
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
|
||||
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
|
||||
assertThat(calendars.updatedManagedFields).isEmpty()
|
||||
assertThat(calendars.deletedEventIds).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||
engine.sync()
|
||||
|
||||
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
|
||||
engine.sync()
|
||||
|
||||
assertThat(calendars.updatedManagedFields).hasSize(1)
|
||||
val (_, columns) = calendars.updatedManagedFields.single()
|
||||
// Only managed columns are ever written — never reminders/location.
|
||||
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
|
||||
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||
engine.sync()
|
||||
|
||||
contacts.dates = emptyList()
|
||||
engine.sync()
|
||||
|
||||
assertThat(calendars.deletedEventIds).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||
engine.sync()
|
||||
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
|
||||
|
||||
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||
engine.sync()
|
||||
|
||||
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
|
||||
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||
engine.sync()
|
||||
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||
|
||||
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
|
||||
|
||||
// Persisted as the per-calendar all-day override (so new events match)...
|
||||
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
|
||||
.isEqualTo(listOf(0, 1440))
|
||||
// ...and applied to the existing events in that calendar.
|
||||
assertThat(calendars.appliedManagedReminders).contains(
|
||||
Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
|
||||
val calendars = FakeCalendarDataSource()
|
||||
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||
engine.sync()
|
||||
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||
|
||||
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
|
||||
|
||||
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
|
||||
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
|
||||
}
|
||||
|
||||
// --- pure diff ---
|
||||
|
||||
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||
ManagedEventDesired(uid, title, dt, rrule)
|
||||
|
||||
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||
ManagedEventRow(id, uid, title, dt, rrule)
|
||||
|
||||
@Test
|
||||
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
|
||||
val diff = diffManagedEvents(
|
||||
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
|
||||
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
|
||||
)
|
||||
assertThat(diff.insertUids).containsExactly("new")
|
||||
assertThat(diff.deleteEventIds).containsExactly(2L)
|
||||
assertThat(diff.updates).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diff emits only the changed managed columns`() {
|
||||
val diff = diffManagedEvents(
|
||||
desired = listOf(desired("a", "Janet", 150)),
|
||||
existing = listOf(row(9, "a", "Jane", 100)),
|
||||
)
|
||||
val update = diff.updates.single()
|
||||
assertThat(update.eventId).isEqualTo(9L)
|
||||
assertThat(update.columns.keys)
|
||||
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,8 @@ 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 de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -152,78 +150,6 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
val config = prefs.quickSwitchConfig.first()
|
||||
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
|
||||
assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
val config = QuickSwitchConfig(
|
||||
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
|
||||
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||
)
|
||||
prefs.setQuickSwitchConfig(config)
|
||||
val loaded = prefs.quickSwitchConfig.first()
|
||||
assertThat(loaded.order).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
|
||||
).inOrder()
|
||||
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
|
||||
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram"
|
||||
m
|
||||
}
|
||||
val config = prefs.quickSwitchConfig.first()
|
||||
// Stored order first (Day, Week), then the omitted views in default order.
|
||||
assertThat(config.order).containsExactly(
|
||||
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||
).inOrder()
|
||||
// Day was explicitly disabled; the appended Month & Agenda default enabled.
|
||||
assertThat(config.enabled).containsExactly(
|
||||
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||
prefs.setDrawerViewOrder(
|
||||
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
|
||||
)
|
||||
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
@@ -415,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.specialDatesShowYear.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||
assertThat(prefs.specialDatesTypes.first())
|
||||
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
|
||||
assertThat(prefs.specialDatesCalendars.first())
|
||||
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
|
||||
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
|
||||
|
||||
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
|
||||
assertThat(prefs.specialDatesCalendars.first())
|
||||
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
|
||||
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
|
||||
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
|
||||
.isEqualTo("{name}'s birthday")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.specialDatesStatus.first())
|
||||
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
|
||||
|
||||
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
|
||||
assertThat(prefs.specialDatesStatus.first())
|
||||
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
|
||||
|
||||
prefs.recordSpecialDatesRun(2000L, null)
|
||||
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventColorPaletteTest {
|
||||
|
||||
@Test
|
||||
fun `empty palette stays empty`() {
|
||||
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact duplicate values collapse to the alphabetically first key`() {
|
||||
val curated = listOf(
|
||||
EventColorOption("cyan", 0xFF00FFFF.toInt()),
|
||||
EventColorOption("aqua", 0xFF00FFFF.toInt()),
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker()
|
||||
|
||||
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small palettes pass through whole, so Google's curated set is untouched`() {
|
||||
// A Google-like palette: two dozen distinct hand-picked colours.
|
||||
val palette = (0 until 24).map {
|
||||
val hue = it * 15
|
||||
EventColorOption("$it", hsvArgb(hue.toFloat()))
|
||||
}
|
||||
|
||||
val curated = palette.curatedForPicker()
|
||||
|
||||
assertThat(curated).containsExactlyElementsIn(palette)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
|
||||
val curated = css3Palette().curatedForPicker()
|
||||
|
||||
// The whole point of #22: ~147 published colours become a single
|
||||
// manageable grid instead of a full screen.
|
||||
assertThat(curated.size).isAtLeast(30)
|
||||
assertThat(curated.size).isAtMost(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `curation never invents colours or drops keys`() {
|
||||
val source = css3Palette()
|
||||
val curated = source.curatedForPicker()
|
||||
|
||||
assertThat(source).containsAtLeastElementsIn(curated)
|
||||
assertThat(curated.map { it.argb }).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spelling-alias pairs never both survive`() {
|
||||
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
|
||||
|
||||
val aliasPairs = listOf(
|
||||
"aqua" to "cyan",
|
||||
"fuchsia" to "magenta",
|
||||
"gray" to "grey",
|
||||
"darkgray" to "darkgrey",
|
||||
"dimgray" to "dimgrey",
|
||||
"lightgray" to "lightgrey",
|
||||
"slategray" to "slategrey",
|
||||
"lightslategray" to "lightslategrey",
|
||||
"darkslategray" to "darkslategrey",
|
||||
)
|
||||
aliasPairs.forEach { (a, b) ->
|
||||
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
|
||||
// Black and every gray paint as the same pale swatch (the picker pins
|
||||
// lightness and floors saturation), so only one survives — no stranded
|
||||
// run of look-alike "pinks" at the end of the grid (#22).
|
||||
val curated = listOf(
|
||||
EventColorOption("black", 0xFF000000.toInt()),
|
||||
EventColorOption("gray", 0xFF808080.toInt()),
|
||||
EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
|
||||
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker().map { it.key }
|
||||
|
||||
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
|
||||
assertThat(curated).containsAtLeast("black", "red", "blue")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dark and a light shade of one hue collapse to a single swatch`() {
|
||||
// The picker paints every swatch at one fixed lightness, so navy and a
|
||||
// mid blue are indistinguishable once painted — keep just one.
|
||||
val curated = listOf(
|
||||
EventColorOption("navy", 0xFF000080.toInt()),
|
||||
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||
).curatedForPicker()
|
||||
|
||||
assertThat(curated).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel is cut once, keeping each hue family contiguous`() {
|
||||
// Twelve pure hues, deliberately shuffled; a small palette passes the
|
||||
// thinning stage untouched so only the ordering is under test.
|
||||
val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270)
|
||||
val curated = shuffledHues
|
||||
.map { EventColorOption("$it", hsvArgb(it.toFloat())) }
|
||||
.curatedForPicker()
|
||||
.map { it.key.toInt() }
|
||||
|
||||
// A proper single-seam sweep around the wheel descends exactly once
|
||||
// (at the seam). The old bucketed sort could scatter a family across
|
||||
// both ends, producing extra descents.
|
||||
val descents = curated.indices.count { i ->
|
||||
curated[(i + 1) % curated.size] < curated[i]
|
||||
}
|
||||
assertThat(descents).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CSS3 survivors span the whole rainbow`() {
|
||||
val keys = css3Palette().curatedForPicker().map { it.key }
|
||||
fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } }
|
||||
|
||||
// Which exact name represents a hue family depends on the vivid-first
|
||||
// thinning, so assert each family survives, not a specific key.
|
||||
assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue()
|
||||
assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue()
|
||||
assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue()
|
||||
assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue()
|
||||
assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue()
|
||||
}
|
||||
|
||||
private fun hsvArgb(hue: Float): Int {
|
||||
val h = hue / 60f
|
||||
val sector = h.toInt() % 6
|
||||
val f = h - h.toInt()
|
||||
val q = ((1 - f) * 255).toInt()
|
||||
val t = (f * 255).toInt()
|
||||
return when (sector) {
|
||||
0 -> argb(255, t, 0)
|
||||
1 -> argb(q, 255, 0)
|
||||
2 -> argb(0, 255, t)
|
||||
3 -> argb(0, q, 255)
|
||||
4 -> argb(t, 0, 255)
|
||||
else -> argb(255, 0, q)
|
||||
}
|
||||
}
|
||||
|
||||
private fun argb(r: Int, g: Int, b: Int): Int =
|
||||
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
|
||||
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
|
||||
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
|
||||
EventColorOption(name, 0xFF000000.toInt() or rgb)
|
||||
}
|
||||
|
||||
private val CSS3 = mapOf(
|
||||
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
|
||||
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
|
||||
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
|
||||
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
|
||||
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
|
||||
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
|
||||
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
|
||||
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
|
||||
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
|
||||
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
|
||||
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
|
||||
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
|
||||
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
|
||||
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
|
||||
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
|
||||
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
|
||||
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
|
||||
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
|
||||
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
|
||||
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
|
||||
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
|
||||
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
|
||||
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
|
||||
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
|
||||
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
|
||||
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
|
||||
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
|
||||
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
|
||||
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
|
||||
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
|
||||
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
|
||||
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
|
||||
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
|
||||
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
|
||||
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
|
||||
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
|
||||
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
|
||||
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
|
||||
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
|
||||
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
|
||||
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
|
||||
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
|
||||
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
|
||||
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
|
||||
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
|
||||
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
|
||||
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
|
||||
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
|
||||
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
|
||||
"yellowgreen" to 0x9ACD32,
|
||||
)
|
||||
}
|
||||
@@ -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,47 @@
|
||||
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 `title substitutes name and year`() {
|
||||
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
|
||||
.isEqualTo("Jane's birthday (1990)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title drops an empty year parenthesis and tidies spacing`() {
|
||||
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
|
||||
.isEqualTo("Jane's birthday")
|
||||
}
|
||||
}
|
||||
@@ -91,33 +91,4 @@ class ViewBackStackTest {
|
||||
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
|
||||
.containsExactly(CalendarView.Agenda)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next cycles through the configured views in order, wrapping around`() {
|
||||
val cycle = listOf(CalendarView.Month, CalendarView.Agenda)
|
||||
assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda)
|
||||
// Wraps back to the first from the last.
|
||||
assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next from a view outside the cycle lands on the first enabled view`() {
|
||||
// Day was disabled in the quick-switch cycle but is still the current view.
|
||||
val cycle = listOf(CalendarView.Week, CalendarView.Month)
|
||||
assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next on an empty cycle stays put`() {
|
||||
assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch cycle keeps enabled views in their configured order`() {
|
||||
val config = QuickSwitchConfig(
|
||||
order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week),
|
||||
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||
)
|
||||
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
248
docs/design/contact-special-dates.md
Normal file
248
docs/design/contact-special-dates.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
|
||||
|
||||
Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
|
||||
Date: 2026-06-30 (implemented 2026-07-01)
|
||||
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
|
||||
|
||||
Implementation notes / deviations from this design:
|
||||
|
||||
- **Event identity key is a deterministic `Events.UID_2445`**
|
||||
(`contact-<type>:<lookupKey>@calendula`), not `SYNC_DATA1`. On a LOCAL calendar
|
||||
the provider drops `_sync` columns unless written through a sync-adapter URI;
|
||||
`UID_2445` is written/read through the normal event URIs the app already uses
|
||||
(and doubles as a stable iCal UID). The managed **calendar** still carries a
|
||||
`CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
|
||||
sync-adapter URI).
|
||||
- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
|
||||
age is impossible without per-year exception rows (rejected as too heavy). The
|
||||
template instead offers `{year}` — the birth year (or an anniversary's start
|
||||
year) — which is static and correct on every occurrence. A "Show year" toggle
|
||||
gates it; it's in the default templates ("{name}'s birthday ({year})").
|
||||
- New managed calendars seed a per-calendar all-day reminder default of
|
||||
on-the-day + one week before, so birthdays get lead time out of the box.
|
||||
- Title template is user-editable per type from day one (`{name}`, `{year}`).
|
||||
|
||||
This document captures the full design for surfacing contact birthdays —
|
||||
and, by extension, anniversaries and custom dates — as auto-updating local
|
||||
calendars in Calendula. It also specifies #14 (per-calendar *multiple*
|
||||
default reminders), which is a hard prerequisite and ships first.
|
||||
|
||||
The two pieces are deliberately split across **two branches / two
|
||||
shipments**:
|
||||
|
||||
- `feat/per-calendar-multi-reminders` — #14, ships first.
|
||||
- `feat/contact-special-dates` — #15, builds on #14.
|
||||
|
||||
They *may* release together depending on timing, but #14 is independently
|
||||
useful and is the foundation the special-dates calendars lean on.
|
||||
|
||||
---
|
||||
|
||||
## Why this is worth doing carefully
|
||||
|
||||
Calendula's identity is privacy: **no INTERNET permission**, and historically
|
||||
**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
|
||||
never holds the contacts permission (`EventEditScreen.kt:446-460`).
|
||||
|
||||
This feature **declares `READ_CONTACTS`** for the first time. That is a
|
||||
deliberate, owner-approved shift, mitigated by:
|
||||
|
||||
- The permission is **opt-in and feature-gated** — requested only when the user
|
||||
enables the feature, never at startup.
|
||||
- Everything stays **offline**. Contacts are read locally and mirrored into a
|
||||
local calendar; nothing leaves the device.
|
||||
- The mirror is **one-way** (contacts → calendar). Calendula never writes to
|
||||
Contacts.
|
||||
|
||||
Store listing / About copy will need to explain the optional permission.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisite — #14: per-calendar *multiple* default reminders
|
||||
|
||||
### Today
|
||||
|
||||
- Events already support **multiple reminders** — `EventForm.reminders:
|
||||
List<Int>` (`EventForm.kt:24`), each written as a separate
|
||||
`CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each
|
||||
firing independently. The runtime/notification path is done.
|
||||
- The **defaults** layer is single-valued, though:
|
||||
- Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single
|
||||
`Int` (`SettingsPrefs.kt:287,302`).
|
||||
- Per-calendar override: `perCalendarReminderOverride: Map<Long, Int?>` and
|
||||
the all-day variant (`SettingsPrefs.kt:350-381`), resolved by
|
||||
`resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`).
|
||||
- UI: expandable per-calendar override section
|
||||
(`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`).
|
||||
|
||||
### The change
|
||||
|
||||
Widen the **defaults**, not the runtime, from one reminder to a list:
|
||||
|
||||
1. Resolution model `Int` → `List<Int>` in global defaults + per-calendar
|
||||
override + `resolveDefaultReminder()`. The DataStore string format already
|
||||
parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
|
||||
comma-separated list. Keep the `Inherit / None / Minutes` override states
|
||||
(`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
|
||||
a list.
|
||||
2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
|
||||
UI rather than inventing new interaction.
|
||||
3. New events seed `EventForm.reminders` from the **resolved list** instead of
|
||||
a single value.
|
||||
|
||||
No new permissions, no provider changes, no scheduling changes. Type widening
|
||||
plus one picker upgrade.
|
||||
|
||||
### Why it gates #15
|
||||
|
||||
Each special-dates calendar is a normal local calendar, so it inherits the
|
||||
**per-calendar reminder default** for free. A birthdays calendar is useless
|
||||
without lead time (the reporter literally asked for "a week before *and* on the
|
||||
day") — that requires *multiple* defaults, i.e. #14.
|
||||
|
||||
---
|
||||
|
||||
## #15: Contact special-dates calendars
|
||||
|
||||
### Core model: one-way mirror, one local calendar per type
|
||||
|
||||
Contacts → **separate local calendars per type**:
|
||||
|
||||
- "Birthdays"
|
||||
- "Anniversaries"
|
||||
- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
|
||||
|
||||
Separate-per-type is the key architectural decision: each is a normal local
|
||||
calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
|
||||
**existing per-calendar infra applies for free**:
|
||||
|
||||
- **Color** → existing per-calendar color.
|
||||
- **Show/hide** → existing calendar visibility.
|
||||
- **Default reminders** → per-calendar override from #14.
|
||||
|
||||
This shrinks the new surface dramatically: anniversaries and custom dates are
|
||||
near copy-paste of birthdays (same engine, different `ContactsContract` data
|
||||
kind, one extra local calendar each), and the settings sub-page does **not**
|
||||
need to reinvent color/visibility/reminders.
|
||||
|
||||
### Events: field-level managed, not read-only
|
||||
|
||||
Calendula manages a few fields; the user owns the rest.
|
||||
|
||||
- **Managed** (overwritten on every sync, disabled in the editor for these
|
||||
events): title, start date, recurrence (`FREQ=YEARLY`), existence.
|
||||
- **User-owned** (seeded once on insert, never touched again by sync):
|
||||
location, reminders, notes, busy/free.
|
||||
|
||||
The rule that makes this safe: sync performs a **targeted column update**
|
||||
(managed columns only), **never delete-and-reinsert**. Reminders live in
|
||||
separate rows, so once seeded they are simply never re-touched. A user can move
|
||||
a birthday reminder to "2 weeks before" or add a location and neither is
|
||||
clobbered.
|
||||
|
||||
In the editor, when an event belongs to a managed calendar: disable the
|
||||
title/date/recurrence fields; allow the rest.
|
||||
|
||||
### Reconciliation identity
|
||||
|
||||
Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
|
||||
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
|
||||
|
||||
- contact added → insert
|
||||
- birthday/date changed → targeted update of managed columns
|
||||
- contact or date removed → delete the event
|
||||
|
||||
Getting this wrong = duplicate birthdays on every sync, so the identity key is
|
||||
non-negotiable.
|
||||
|
||||
### Data source
|
||||
|
||||
`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
|
||||
`Event.TYPE`:
|
||||
|
||||
- `TYPE_BIRTHDAY` → Birthdays calendar
|
||||
- `TYPE_ANNIVERSARY` → Anniversaries calendar
|
||||
- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
|
||||
|
||||
### Dates: year-less + age
|
||||
|
||||
Many contacts store `--MM-DD` (no year). Handle both:
|
||||
|
||||
- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
|
||||
age ("Jane turns 30") when the show-age option is on.
|
||||
- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
|
||||
|
||||
Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
|
||||
(`Recurrence.kt:32-174`).
|
||||
|
||||
### Auto-update cadence
|
||||
|
||||
Birthdays change rarely, so nothing live/expensive:
|
||||
|
||||
- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
|
||||
(catches new/edited/deleted contacts even if the app isn't opened).
|
||||
- **No `ContentObserver`** — needs the process alive and buys almost nothing
|
||||
for once-a-year events.
|
||||
|
||||
### Permission + lifecycle
|
||||
|
||||
- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
|
||||
contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
|
||||
grant, create the enabled calendars and run the first sync.
|
||||
- **Disable:** delete the managed calendars + their events (with a confirm).
|
||||
- **Permission revoked in system settings:** detect on next sync; surface that
|
||||
the feature is stalled rather than failing silently.
|
||||
|
||||
### Configurable settings sub-page
|
||||
|
||||
Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
|
||||
color/visibility/reminders already live in normal calendar settings, the
|
||||
sub-page only carries the genuinely new knobs:
|
||||
|
||||
- Master enable toggle.
|
||||
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
|
||||
- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
|
||||
- Show-age toggle (only meaningful when the year is known).
|
||||
- (Possibly) sync-cadence / "sync now".
|
||||
- Pointer to the normal calendar settings for color, visibility, reminders.
|
||||
|
||||
The owner wants this broadly configurable — keep the sub-page the home for
|
||||
display/behaviour options, and lean on existing per-calendar settings for the
|
||||
rest.
|
||||
|
||||
---
|
||||
|
||||
## Open questions / deferred
|
||||
|
||||
- Title template: free-form vs a small set of presets. Start with one good
|
||||
localized default; revisit configurability.
|
||||
- Whether "Custom dates" is one bucket or split further by label. Start: one
|
||||
bucket.
|
||||
- Contact scope: all contacts vs per-account/group filtering. Start: all;
|
||||
filtering is a later refinement.
|
||||
|
||||
## Task outline
|
||||
|
||||
### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
|
||||
- [x] Widen global + per-calendar reminder defaults `Int` → `List<Int>`
|
||||
(`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
|
||||
- [x] `resolveDefaultReminder()` returns a list.
|
||||
- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
|
||||
- [x] Seed new events' `EventForm.reminders` from the resolved list.
|
||||
- [x] Migration / parse compatibility for existing single-value stored prefs.
|
||||
- [x] Tests: resolution, parse round-trip, all-day variant.
|
||||
|
||||
### #15 — contact special-dates (`feat/contact-special-dates`)
|
||||
- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
|
||||
- [x] Per-type local calendars (create on enable, delete on disable+confirm).
|
||||
- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
|
||||
- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
|
||||
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
|
||||
user-owned fields on insert only.
|
||||
- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct).
|
||||
- [x] Auto-update: on enable, on foreground, daily WorkManager.
|
||||
- [x] Editor: disable managed fields for managed-calendar events.
|
||||
- [x] Revoked-permission detection + stalled-state surface.
|
||||
- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
|
||||
- [x] Translatable strings (titles, sub-page).
|
||||
- [x] About / store copy for the optional `READ_CONTACTS` permission.
|
||||
@@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
|
||||
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
||||
Termin.
|
||||
|
||||
Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage
|
||||
deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte
|
||||
werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert —
|
||||
Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll
|
||||
kontrollierst.
|
||||
|
||||
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
||||
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a
|
||||
simple repeat picker. Calendula also delivers your event reminders as
|
||||
notifications — tap one and you're on the event.
|
||||
|
||||
Optionally, show your contacts' birthdays, anniversaries and other special
|
||||
dates in your calendar. You turn this on yourself; contacts are read only on
|
||||
your device, are never uploaded, and are never changed — Calendula only
|
||||
mirrors the dates into local calendars you fully control.
|
||||
|
||||
The differentiator is the design: real Material 3 Expressive throughout,
|
||||
with dynamic color, expressive motion, and expressive shapes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user