Extract repeated helpers in data, domain and UI layers (#416)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped

Step 6 of the simplification review: mechanical helper extractions, one commit per group. Widget code is untouched.

**Data layer** (CalendarDataSource and the mappers)
- `updateEventRow`/`eventUri` replace the seven "update, throw if no row" copies and the loose `withAppendedId` spellings.
- `SeriesRow.timeColumns()` / `exdateValues()` define the DTSTART/RRULE/DURATION/TZ/ALL_DAY set once for truncate, split, detach, delete and exception copy.
- One `insertReminderRow` for the four reminder loops, `insertLocalCalendar` for local and managed creates, `applyManagedCalendarReminders` iterates `queryManagedEvents`, `reconcileAttendees` reuses `insertAttendees`, plus `mapRows`, `instancesUri`, `mintUid`, shared `eventColorColumns` and `switchedOffCalendarIds`.
- The read-side mappers share `ColumnReader` helpers (end time, status, all-day UTC date); each keeps its own end-time clamp. `importDuration` goes through `rfc2445Duration` with identical output. The `eventColorPalette` data-source member is gone; the repository calls the pure function.
- One behaviour difference: a provider *exception* while inserting a reminder row was only swallowed on import. It is now logged and skipped on every path (create, update, reconcile, move), as the existing comments already describe it, so it no longer fails the event write.

**Domain**
- `BasicFormat.kt` replaces the four `%04d…` formatters and three substring parsers (ICS writer/parser, Recurrence, RecurrenceText). Parsing stays as lenient as before.
- `rruleParts()` for the RRULE tokenising in five places; each caller keeps its own malformed-part handling. `rruleTruncatedAt` keeps its own tokeniser since it preserves garbage parts verbatim.
- `allDayFormTimes` and `normalizedRrule` for the three form-prefill copies, `zoneOrNull` for the eight zone guards, EventSearch orders months by `YearMonth`, IcsParser uses duration arithmetic and `firstOrNull`.
- FREQ matching no longer depends on the device locale (a lowercase rule failed to parse on Turkish-locale phones).
- Skipped: the contact-date parser rewrite (tests don't pin enough inputs) and the writer-side enum tables (their exhaustive `when` is a useful compile check).

**UI / view models**
- `dayRange(first, last, zone)` and one `startOfWeek` replace four start..end-of-day helpers and two identical week-start functions.
- `orUnnamed()` replaces 17 `ifBlank { calendar_unnamed }` spellings; a shared `CalendarVisibilitySwitch` and `CalendarManagerRow`.
- Filter uses `CalendarAccountGroup` plus a hidden-id set instead of its own copy of the grouping model; the grouping test moved to `AccountGroupsTest`.
- `agendaDays` is shared by the Agenda screen and the split-month pane.
- kotlinx-datetime's `yearMonth`, `firstDay`/`lastDay` and java.time converters replace about 20 hand-written bridges; one `formatTimeOfDay` for agenda rows and month chips.
- No visible UI change.

Net about −240 lines.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/416
This commit is contained in:
Jean-Luc Makiola
2026-10-16 13:56:17 +02:00
co-authored by makiolaj
parent bb8c91acc5
commit f55cfffa3a
59 changed files with 728 additions and 970 deletions
@@ -38,7 +38,6 @@ import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.toEditForm import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -76,21 +75,13 @@ interface CalendarDataSource {
/** /**
* The event-colour palette the calendar's account publishes * The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped, * (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order.
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* Empty when the account exposes no palette (most local calendars, some * 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 * 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. * `EVENT_COLOR`, which a synced calendar may drop on its next sync. The
*/ * picker shows it curated ([curatedForPicker]); matching a colour that came
fun eventColorPalette(calendarId: Long): List<EventColorOption> * from outside the account runs against this full set, since those keys are
* all the calendar accepts.
/**
* The same palette **uncurated** — every key the account publishes, in
* provider order. Curation is a display concession (it folds look-alikes and
* drops the neutrals outright from an oversized palette), so matching a
* colour that came from outside the account has to run against the full set:
* those keys are all the calendar accepts. See [eventColorPalette].
*/ */
fun publishedEventColors(calendarId: Long): List<EventColorOption> fun publishedEventColors(calendarId: Long): List<EventColorOption>
@@ -359,7 +350,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarProjection.COLUMNS, CalendarProjection.COLUMNS,
null, null, null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList() )?.use { c -> c.mapRows { toCalendarSource() } } ?: emptyList()
} }
/** /**
@@ -376,7 +367,20 @@ class AndroidCalendarDataSource @Inject constructor(
) )
.build() .build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long { override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long =
insertLocalCalendar(displayName, color, "create local calendar") { putDescription(description) }
/**
* Insert an owned, visible, syncing calendar on the Calendula local account;
* [extra] adds the columns that tell the kinds apart. [what] names the write
* in the failure — never the calendar name, which can reach a crash report.
*/
private fun insertLocalCalendar(
displayName: String,
color: Int,
what: String,
extra: ContentValues.() -> Unit,
): Long {
val name = displayName.trim() val name = displayName.trim()
val values = ContentValues().apply { val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
@@ -392,11 +396,9 @@ class AndroidCalendarDataSource @Inject constructor(
) )
put(CalendarContract.Calendars.VISIBLE, 1) put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1) put(CalendarContract.Calendars.SYNC_EVENTS, 1)
putDescription(description) extra()
} }
val uri = resolver.insert(localCalendarsUri(), values) val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what)
// No calendar name in the message — it can reach a crash report.
?: throw WriteFailedException("create local calendar")
return ContentUris.parseId(uri) return ContentUris.parseId(uri)
} }
@@ -463,30 +465,13 @@ class AndroidCalendarDataSource @Inject constructor(
override fun canWriteCalendars(): Boolean = override fun canWriteCalendars(): Boolean =
context.hasPermission(Manifest.permission.WRITE_CALENDAR) context.hasPermission(Manifest.permission.WRITE_CALENDAR)
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long =
val name = displayName.trim() insertLocalCalendar(displayName, color, "create managed 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, // The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already // independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2. // holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type)) 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( override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI, CalendarContract.Calendars.CONTENT_URI,
@@ -550,22 +535,16 @@ class AndroidCalendarDataSource @Inject constructor(
// Seeded once, then never re-touched by sync — so a user who moves a // Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. Encoded against the upcoming // birthday reminder keeps their change. Encoded against the upcoming
// occurrence, not the ancient DTSTART anchor (wrong timezone offset). // occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows( insertReminderRows(
eventId, eventId,
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes), managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes),
) )
return eventId return eventId
} }
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) { override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return if (values.isEmpty()) return
val rows = resolver.update( updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
} }
override fun applyManagedCalendarReminders( override fun applyManagedCalendarReminders(
@@ -573,38 +552,21 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
minutes: List<Int>, minutes: List<Int>,
) { ) {
resolver.query( // Only our own mirror events, so a stray user event in this calendar
CalendarContract.Events.CONTENT_URI, // isn't all-day-re-encoded or stripped of its own reminders.
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART), queryManagedEvents(calendarId).forEach { event ->
// Only our own mirror events, so a stray user event in this calendar // Managed events are all-day: DTSTART is a UTC midnight anchor.
// isn't all-day-re-encoded or stripped of its own reminders. reconcileReminders(
"${CalendarContract.Events.CALENDAR_ID} = ? AND " + event.eventId,
"${CalendarContract.Events.DELETED} = 0 AND " + managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes),
"${CalendarContract.Events.UID_2445} LIKE ?", )
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
reconcileReminders(
eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
)
}
} }
} }
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
return resolver.query( return resolver.query(
uri, instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS, InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a // Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a // cancelled exception for the one instance (#47). A NULL status is a
@@ -617,7 +579,7 @@ class AndroidCalendarDataSource @Inject constructor(
"${CalendarContract.Events.DELETED} = 0", "${CalendarContract.Events.DELETED} = 0",
null, null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
} }
override fun searchEvents(query: String): List<SearchCandidate> { override fun searchEvents(query: String): List<SearchCandidate> {
@@ -721,13 +683,9 @@ class AndroidCalendarDataSource @Inject constructor(
endMillis: Long, endMillis: Long,
soonestFirst: Boolean, soonestFirst: Boolean,
): Pair<Long, Long>? { ): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC" val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query( return resolver.query(
uri, instancesUri(beginMillis, endMillis),
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END), arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?", "${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
@@ -741,7 +699,7 @@ class AndroidCalendarDataSource @Inject constructor(
val attendees = queryAttendees(eventId) val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId) val reminders = queryReminders(eventId)
return resolver.query( return resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
EventDetailProjection.COLUMNS, EventDetailProjection.COLUMNS,
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -752,9 +710,6 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> { override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList() val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query( return resolver.query(
@@ -807,11 +762,10 @@ class AndroidCalendarDataSource @Inject constructor(
null, null,
CalendarContract.Events.DTSTART + " ASC", CalendarContract.Events.DTSTART + " ASC",
)?.use { c -> )?.use { c ->
c.mapAll { c.mapRows {
val reader = CursorColumnReader(c) val eventId = getLong(EventExportProjection.IDX_ID)
val eventId = reader.getLong(EventExportProjection.IDX_ID) val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID) toIcsEvent(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes }, reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId], calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes, allDayReminderTimeMinutes = allDayReminderTimeMinutes,
@@ -846,7 +800,7 @@ class AndroidCalendarDataSource @Inject constructor(
calendarId = calendarId, calendarId = calendarId,
// Preserve the file's UID so a re-import dedups against it; mint one // Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none. // only when the source event carried none.
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula", uid = event.uid?.takeIf { it.isNotBlank() } ?: mintUid(),
palette = colorPalette, palette = colorPalette,
) )
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
@@ -859,8 +813,7 @@ class AndroidCalendarDataSource @Inject constructor(
// (see [importedAllDayReminderDate]). // (see [importedAllDayReminderDate]).
val reminderDate = if (event.isAllDay) { val reminderDate = if (event.isAllDay) {
importedAllDayReminderDate( importedAllDayReminderDate(
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) startDate = utcDate(event.start.toEpochMilliseconds()),
.atZone(ZoneOffset.UTC).toLocalDate(),
recurrenceRule = event.recurrenceRule, recurrenceRule = event.recurrenceRule,
today = LocalDate.now(zone), today = LocalDate.now(zone),
) )
@@ -878,24 +831,7 @@ class AndroidCalendarDataSource @Inject constructor(
} else { } else {
minutes minutes
} }
val reminder = ContentValues().apply { insertReminderRow(eventId, providerMinutes)
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
// The event row is already in. A reminder that won't attach costs
// that one alarm, not the event: throwing here would have the import
// count an event it did create as failed, and the retry would then
// skip it as a duplicate.
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
null
}
if (attached == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
} }
return eventId return eventId
} }
@@ -951,18 +887,13 @@ class AndroidCalendarDataSource @Inject constructor(
// back to a stable synthesised UID at export time (deriveIcsUid). // back to a stable synthesised UID at export time (deriveIcsUid).
val values = buildEventInsertValues( val values = buildEventInsertValues(
form, form,
uid = "${UUID.randomUUID()}@calendula", uid = mintUid(),
times = form.toWriteTimes(ZoneId.systemDefault()), times = form.toWriteTimes(ZoneId.systemDefault()),
).toContentValues().apply { ) + eventColorColumns(form.colorKey, form.color)
// A null colour just leaves both columns unset (the event inherits // A new row has nothing to clear: unset columns inherit the
// its calendar's colour), so only the key/raw cases are written. // calendar's colour, so only the key/raw values are written.
when { .filterValues { it != null }
form.colorKey != null -> val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
}
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri) val eventId = ContentUris.parseId(uri)
seedReminders(eventId, form, allDayReminderTimeMinutes) seedReminders(eventId, form, allDayReminderTimeMinutes)
@@ -983,16 +914,32 @@ class AndroidCalendarDataSource @Inject constructor(
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */ /** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) { private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { minutes -> encodedMinutes.forEach { insertReminderRow(eventId, it) }
val reminder = ContentValues().apply { }
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes) /**
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) * Best effort: the event row is already in, so a reminder that won't attach
} * costs that one alarm, not the event. Throwing would report an event that
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { * was created as failed — and an import retry would then skip it as a
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") * duplicate.
} */
private fun insertReminderRow(
eventId: Long,
minutes: Int,
method: Int = CalendarContract.Reminders.METHOD_ALERT,
) {
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, method)
} }
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for event $eventId", e)
null
}
if (attached == null) Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
} }
/** /**
@@ -1029,12 +976,7 @@ class AndroidCalendarDataSource @Inject constructor(
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) )
if (values.isNotEmpty()) { if (values.isNotEmpty()) {
val rows = resolver.update( updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update event id=$eventId")
} }
// Untouched reminder sets are left alone so unrelated edits can't // Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the // disturb provider rows the form never knew about. The diff is on the
@@ -1067,7 +1009,7 @@ class AndroidCalendarDataSource @Inject constructor(
?: throw WriteFailedException("read event to move id=$eventId") ?: throw WriteFailedException("read event to move id=$eventId")
// Keep the source UID so an .ics backup dedups and a sync adapter can // Keep the source UID so an .ics backup dedups and a sync adapter can
// recognise the moved event; mint one only if the source carried none. // recognise the moved event; mint one only if the source carried none.
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula" val uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid()
val newEventId = resolver.insert( val newEventId = resolver.insert(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(), buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
@@ -1096,7 +1038,7 @@ class AndroidCalendarDataSource @Inject constructor(
/** The master row of the event to move, as a verbatim-insert snapshot. */ /** The master row of the event to move, as a verbatim-insert snapshot. */
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query( private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
MoveMasterProjection.COLUMNS, MoveMasterProjection.COLUMNS,
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -1108,8 +1050,7 @@ class AndroidCalendarDataSource @Inject constructor(
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(), title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0, isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART), dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND) dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND),
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
duration = r.getString(MoveMasterProjection.IDX_DURATION), duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }, rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE), rdate = r.getString(MoveMasterProjection.IDX_RDATE),
@@ -1117,8 +1058,7 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE), timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY), availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL), accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
status = r.getInt(MoveMasterProjection.IDX_STATUS) status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS),
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
location = r.getString(MoveMasterProjection.IDX_LOCATION), location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION), description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
), ),
@@ -1135,16 +1075,7 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(fromEventId.toString()), arrayOf(fromEventId.toString()),
null, null,
)?.use { c -> )?.use { c ->
while (c.moveToNext()) { while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1))
val values = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, toEventId)
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
put(CalendarContract.Reminders.METHOD, c.getInt(1))
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
}
}
} ?: Unit } ?: Unit
/** /**
@@ -1189,22 +1120,8 @@ class AndroidCalendarDataSource @Inject constructor(
.toDetachedOccurrence() .toDetachedOccurrence()
detached += insertEvent(form, allDayReminderTimeMinutes) detached += insertEvent(form, allDayReminderTimeMinutes)
} }
val row = querySeriesRow(toEventId) val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis })
val values = buildOccurrencesExdateValues( updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId")
existingExdate = row.exdate,
occurrences = exceptions.map { it.originalInstanceMillis },
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
val updated = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, toEventId),
values.toContentValues(), null, null,
)
if (updated == 0) throw WriteFailedException("exdate moved exceptions event id=$toEventId")
} }
/** The series' exception rows (modified + cancelled), oldest occurrence first. */ /** The series' exception rows (modified + cancelled), oldest occurrence first. */
@@ -1215,18 +1132,14 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(seriesEventId.toString()), arrayOf(seriesEventId.toString()),
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC", CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
)?.use { c -> )?.use { c ->
c.mapAll { c.mapRows {
val r = CursorColumnReader(c)
val status = r.getInt(ExceptionProjection.IDX_STATUS)
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
ExceptionRowSnapshot( ExceptionRowSnapshot(
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID), exceptionEventId = getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME), originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = status == CalendarContract.Events.STATUS_CANCELED, isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED,
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART), dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND) dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND),
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) }, duration = getString(ExceptionProjection.IDX_DURATION),
duration = r.getString(ExceptionProjection.IDX_DURATION),
) )
} }
} ?: emptyList() } ?: emptyList()
@@ -1263,7 +1176,7 @@ class AndroidCalendarDataSource @Inject constructor(
val exceptionId = ContentUris.parseId(uri) val exceptionId = ContentUris.parseId(uri)
form.colorKey?.let { key -> form.colorKey?.let { key ->
resolver.update( resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId), eventUri(exceptionId),
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) }, ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
null, null, null, null,
) )
@@ -1317,34 +1230,19 @@ class AndroidCalendarDataSource @Inject constructor(
// a fresh UID — the detached row is a separate event now, and sharing the // a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup. // parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes) val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still // Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the // exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match. // provider's expansion fails to match.
val updatedRows = try { try {
resolver.update( updateEventRow(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventId,
values.toContentValues(), null, null, row.exdateValues(listOf(beginMillis)),
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
) )
} catch (t: Throwable) { } catch (t: Throwable) {
rollBackDetached(detachedId) rollBackDetached(detachedId)
throw t throw t
} }
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId return detachedId
} }
@@ -1417,23 +1315,11 @@ class AndroidCalendarDataSource @Inject constructor(
updated = updated, updated = updated,
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) ?: return ) ?: return
val row = querySeriesRow(newEventId) val values = querySeriesRow(newEventId).timeColumns().apply {
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried) put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
} }
try { try {
val rows = resolver.update( updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
} catch (t: Throwable) { } catch (t: Throwable) {
runCatching { deleteEvent(newEventId) } runCatching { deleteEvent(newEventId) }
throw t throw t
@@ -1461,29 +1347,18 @@ class AndroidCalendarDataSource @Inject constructor(
*/ */
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) { private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" } requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
val values = ContentValues().apply { val values = row.timeColumns().apply {
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
put( put(
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)), rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
) )
} }
val rows = resolver.update( updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values,
null, null,
)
if (rows == 0) {
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
}
} }
/** The series anchor: every time-related column of the Events row. */ /** The series anchor: every time-related column of the Events row. */
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query( private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
arrayOf( arrayOf(
CalendarContract.Events.DTSTART, CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
@@ -1531,6 +1406,30 @@ class AndroidCalendarDataSource @Inject constructor(
*/ */
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank() val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The time/recurrence set a series write carries together: the provider
* re-expands instances only from what the update itself holds.
*/
fun timeColumns(): ContentValues = ContentValues().apply {
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.RRULE, rrule)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
}
/** This row's columns with [occurrences] added to EXDATE ([buildOccurrencesExdateValues]). */
fun exdateValues(occurrences: List<Long>): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate = exdate,
occurrences = occurrences,
dtStartMillis = dtStartMillis,
rrule = rrule,
rdate = rdate,
duration = duration,
timezone = timezone,
allDay = allDay,
)
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */ /** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis( fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis, beginMillis = beginMillis,
@@ -1556,20 +1455,10 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(eventId.toString(), minutes.toString()), arrayOf(eventId.toString(), minutes.toString()),
) )
} }
(target - existing).forEach { minutes -> insertReminderRows(eventId, (target - existing).toList())
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
} }
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */ private fun EventAttendee.key(): String = emailKey(email)
private fun EventAttendee.key(): String = email.trim().lowercase()
/** Distinct guests by email; blanks dropped (we key and write on the address). */ /** Distinct guests by email; blanks dropped (we key and write on the address). */
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> { private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
@@ -1621,7 +1510,7 @@ class AndroidCalendarDataSource @Inject constructor(
val relationship: Int, val relationship: Int,
val type: Int, val type: Int,
) { ) {
val key: String get() = email.trim().lowercase() val key: String get() = emailKey(email)
} }
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query( private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
@@ -1686,17 +1575,20 @@ class AndroidCalendarDataSource @Inject constructor(
) )
} }
} }
target.filterNot { it.key() in editableKeys }.forEach { attendee -> insertAttendees(eventId, target.filterNot { it.key() in editableKeys })
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
} }
private fun eventUri(eventId: Long): Uri =
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
/** Update the Events row [eventId]; [what] names the write when no row was touched. */
private fun updateEventRow(eventId: Long, values: ContentValues, what: String) {
if (resolver.update(eventUri(eventId), values, null, null) == 0) throw WriteFailedException(what)
}
private fun updateEventRow(eventId: Long, values: Map<String, Any?>, what: String) =
updateEventRow(eventId, values.toContentValues(), what)
private fun Map<String, Any?>.toContentValues(): ContentValues = private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv -> ContentValues().also { cv ->
forEach { (column, value) -> forEach { (column, value) ->
@@ -1713,7 +1605,7 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteEvent(eventId: Long) { override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete( val deleted = resolver.delete(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
null, null, null, null,
) )
if (deleted == 0) throw WriteFailedException("delete event id=$eventId") if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
@@ -1735,23 +1627,11 @@ class AndroidCalendarDataSource @Inject constructor(
// occurrence with it (#47 on a local calendar). EXDATE needs no link. // occurrence with it (#47 on a local calendar). EXDATE needs no link.
// Calendula's own contact special-date calendars are local and hold // Calendula's own contact special-date calendars are local and hold
// yearly series, so this path is reached in normal use. // yearly series, so this path is reached in normal use.
val values = buildOccurrenceExdateValues( updateEventRow(
existingExdate = row.exdate, eventId,
occurrenceMillis = beginMillis, row.exdateValues(listOf(beginMillis)),
dtStartMillis = row.dtStartMillis, "exdate occurrence event id=$eventId begin=$beginMillis",
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
) )
val updated = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
if (updated == 0) {
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
}
return return
} }
// A cancelled exception row hides exactly this occurrence; the sync // A cancelled exception row hides exactly this occurrence; the sync
@@ -1801,7 +1681,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Attendees.EVENT_ID + " = ?", CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
null, null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList() )?.use { c -> c.mapRows { toAttendee() } } ?: emptyList()
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query( private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
CalendarContract.Reminders.CONTENT_URI, CalendarContract.Reminders.CONTENT_URI,
@@ -1809,19 +1689,15 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Reminders.EVENT_ID + " = ?", CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
null, null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList() )?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */ /** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList { private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll)) while (moveToNext()) add(mapper(this@mapAll))
} }
/** Iterate every row and map; drops nulls. */ /** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList { private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */ /** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) { private fun markerFor(type: SpecialDateType): String = when (type) {
@@ -1837,6 +1713,9 @@ class AndroidCalendarDataSource @Inject constructor(
private companion object { private companion object {
const val TAG = "CalendarDataSource" const val TAG = "CalendarDataSource"
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
fun emailKey(email: String): String = email.trim().lowercase()
/** /**
* Shared account for every app-created local calendar, so they group * Shared account for every app-created local calendar, so they group
* together (by account) in the filter sheet and calendar manager. * together (by account) in the filter sheet and calendar manager.
@@ -29,7 +29,7 @@ interface CalendarRepository {
/** /**
* The event-colour palette a calendar's account publishes; empty when it * The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]). * exposes none (see [CalendarDataSource.publishedEventColors]).
*/ */
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
@@ -125,8 +126,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis = range.start.toEpochMillis(), beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(), endMillis = range.endInclusive.toEpochMillis(),
), ),
switchedOffCalendarIds = invisibleCalendarIds() + switchedOffCalendarIds = switchedOffCalendarIds(),
prefs.pendingDisabledCalendarIds.first(),
) )
}, },
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
@@ -151,6 +151,10 @@ class CalendarRepositoryImpl @Inject constructor(
.filterNot { it.isVisibleInSystem } .filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id } .mapTo(mutableSetOf()) { it.id }
/** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */
private suspend fun switchedOffCalendarIds(): Set<Long> =
invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first()
private val calendarsLock = Mutex() private val calendarsLock = Mutex()
private var cachedGeneration = -1L private var cachedGeneration = -1L
private var cachedPending: Set<Long>? = null private var cachedPending: Set<Long>? = null
@@ -184,15 +188,13 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) { override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds()
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } } .let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
} }
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) } withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() }
override suspend fun createLocalCalendar( override suspend fun createLocalCalendar(
displayName: String, displayName: String,
@@ -214,16 +216,15 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) = override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
write { write {
if (dataSource.canWriteCalendars()) { val canWrite = dataSource.canWriteCalendars()
ids.forEach { dataSource.setCalendarVisible(it, visible) } if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the // Nothing of ours is left waiting for the provider once the write
// write lands (and switching one back on retires its entry). // lands, and switching one back on retires its entry. With read-only
prefs.removePendingDisabledCalendarIds(ids) // permission a switch-off still works, app-side, and the reconciler
} else if (visible) { // flushes it if WRITE_CALENDAR ever arrives.
if (canWrite || visible) {
prefs.removePendingDisabledCalendarIds(ids) prefs.removePendingDisabledCalendarIds(ids)
} else { } else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids) prefs.addPendingDisabledCalendarIds(ids)
} }
} }
@@ -1,6 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor import android.database.Cursor
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/** /**
* Read-only view over a single row's columns by index. Lets the mappers work * Read-only view over a single row's columns by index. Lets the mappers work
@@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getInt(index: Int): Int = cursor.getInt(index) override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index) override fun isNull(index: Int): Boolean = cursor.isNull(index)
} }
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
/**
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
*/
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
/** Map every row, reading each through one [CursorColumnReader]. */
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) add(reader.map()) }
}
/** [mapRows], dropping the rows [map] returns null for. */
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) reader.map()?.let(::add) }
}
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
@@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper" private const val TAG = "EventDetailMapper"
@@ -48,11 +46,8 @@ internal fun ColumnReader.toEventDetailCore(
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would // — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
// surface as the generic error screen with no way to open the event and fix // surface as the generic error screen with no way to open the event and fix
// it. Clamp to a zero-length event instead (matching SearchMapper). // it. Clamp to a zero-length event instead (matching SearchMapper).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) { val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)) ?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
// Kept raw (no untitled fallback): the detail screen substitutes its own // Kept raw (no untitled fallback): the detail screen substitutes its own
// localized placeholder, and the edit form must prefill the true value. // localized placeholder, and the edit form must prefill the true value.
@@ -61,11 +56,7 @@ internal fun ColumnReader.toEventDetailCore(
// The event's own colour (null = inherits the calendar's) is kept apart // The event's own colour (null = inherits the calendar's) is kept apart
// from the resolved display colour: the edit form needs to tell the two // from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display. // cases apart, while the instance carries the calendar fallback for display.
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
null
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY) val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR) val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
@@ -88,7 +79,7 @@ internal fun ColumnReader.toEventDetailCore(
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the // and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date. // event's date is its UTC date.
val displayReminders = if (isAllDay) { val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate() val startDate = utcDate(begin)
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
reminders.map { reminders.map {
it.copy( it.copy(
@@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore(
reminders reminders
} }
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail( return EventDetail(
instance = instance, instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION), description = getString(EventDetailProjection.IDX_DESCRIPTION),
@@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore(
attendees = attendees, attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE), rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = displayReminders, reminders = displayReminders,
status = status, status = eventStatus(EventDetailProjection.IDX_STATUS),
// BUSY and DEFAULT are both 0, so a null column folds into the same // BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed. // default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
@@ -609,7 +609,7 @@ internal fun exdateAfter(
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of * optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half). * a zoned one ([exdateListZone] reads that half).
*/ */
private fun exdateStamps(exdate: String?): List<String> = exdate internal fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters() ?.withoutExdateParameters()
?.split(',') ?.split(',')
?.map { it.trim() } ?.map { it.trim() }
@@ -879,7 +879,7 @@ internal fun buildImportedEventValues(
*/ */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String { private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0) val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S" return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
} }
private fun EventStatus.toProviderStatus(): Int = when (this) { private fun EventStatus.toProviderStatus(): Int = when (this) {
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
/** /**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent] * Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -33,19 +29,8 @@ internal fun ColumnReader.toIcsEvent(
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() } val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0 val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it // Unclamped: the writer renders DTEND as stored.
// so the writer can render DTEND. A missing/blank both means a zero-length event. val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
return IcsEvent( return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart), uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
@@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent(
location = getString(EventExportProjection.IDX_LOCATION), location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION), description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) { reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate() val startDate = utcDate(dtStart)
reminderMinutes.map { reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
} }
} else { } else {
reminderMinutes reminderMinutes
}, },
status = status, status = eventStatus(EventExportProjection.IDX_STATUS),
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName, calendarName = calendarName,
) )
@@ -112,11 +97,7 @@ internal fun exportExDates(
): List<String> { ): List<String> {
if (exdate.isNullOrBlank()) return emptyList() if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone) val zone = exdateListZone(exdate, eventTimezone)
return exdate return exdateStamps(exdate)
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { stamp -> .map { stamp ->
if (isAllDay) { if (isAllDay) {
stamp.substringBefore('T') stamp.substringBefore('T')
@@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
// string, so it belongs to the UI layer where it can be localised (#321). // string, so it belongs to the UI layer where it can be localised (#321).
val title = getString(InstanceProjection.IDX_TITLE).orEmpty() val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) { val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
return EventInstance( return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID), instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
@@ -1,7 +1,16 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentUris
import android.net.Uri
import android.provider.CalendarContract import android.provider.CalendarContract
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
internal object CalendarProjection { internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID, CalendarContract.Calendars._ID,
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/** /**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the * Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
@@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
// long-running birthdays/anniversaries still surface in search. // long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART) val dtStart = getLong(SearchProjection.IDX_DTSTART)
val end = when { val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) .coerceAtLeast(dtStart)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty() val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(SearchProjection.IDX_ID) val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance( return EventInstance(
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor(
) : ReminderInstanceSource { ) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> { override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = instancesUri(fromMillis, toMillis)
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the flag the app's one visibility model writes (#75). // `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means // The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event. // "normal", so a bare `!= CANCELED` would drop every ordinary event.
@@ -148,23 +148,21 @@ enum class EventFormProblem {
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
// All-day events are date-anchored and carry a nominal "UTC" that is an // All-day events are date-anchored and carry a nominal "UTC" that is an
// anchor, not a location, so they never pin a zone. // anchor, not a location, so they never pin a zone.
val pinnedZone = if (instance.isAllDay) { val pinned = if (instance.isAllDay) {
null null
} else { } else {
eventTimezone eventTimezone
?.takeIf { it != zone.id } ?.takeIf { it != zone.id }
// An unparseable id (a malformed sync row) can't be honoured or // An unparseable id (a malformed sync row) can't be honoured or
// shown; fall back to the device zone rather than failing the open. // shown; fall back to the device zone rather than failing the open.
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess } ?.let { id -> zoneOrNull(id)?.let { id to it } }
} }
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone val pinnedZone = pinned?.first
val formZone = pinned?.second ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis) val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis) val finish = Instant.fromEpochMilliseconds(endMillis)
val (start, end) = if (instance.isAllDay) { val (start, end) = if (instance.isAllDay) {
val startDate = begin.toLocalDateTime(TimeZone.UTC).date allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else { } else {
begin.toFormTime(formZone) to finish.toFormTime(formZone) begin.toFormTime(formZone) to finish.toFormTime(formZone)
} }
@@ -180,7 +178,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
reminders = reminders.map { it.minutes }.distinct().sorted(), reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability, availability = availability,
accessLevel = accessLevel, accessLevel = accessLevel,
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = rrule.normalizedRrule(),
// The provider fills EVENT_COLOR from the key, so [color] is the // The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its // swatch either way; a null colour means the event inherits its
// calendar's colour. // calendar's colour.
@@ -232,6 +230,19 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
rowEnd = instance.end, rowEnd = instance.end,
) )
/**
* Form times for an all-day span stored as UTC midnights with an exclusive
* end: the last covered day (never before [start]), with placeholder
* wall-clock times kept in case the user switches the event to timed.
*/
internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<FormTime, FormTime> {
val end = endExclusive?.let { maxOf(start, LocalDate.fromEpochDays(it.toEpochDays() - 1)) } ?: start
return FormTime(LocalDateTime(start, LocalTime(9, 0))) to FormTime(LocalDateTime(end, LocalTime(10, 0)))
}
/** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/** /**
* Whether this form's times mean something else in [target] — a timed event * Whether this form's times mean something else in [target] — a timed event
* pinned to another zone. False when there's nothing to disambiguate: an * pinned to another zone. False when there's nothing to disambiguate: an
@@ -243,8 +254,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
*/ */
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean { fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return false if (isAllDay) return false
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false return resolvedZone(target).id != target.id
return pinned.id != target.id
} }
/** /**
@@ -1,8 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -101,19 +101,13 @@ object EventSearch {
val today = todayStart.toLocalDateTime(zone).date val today = todayStart.toLocalDateTime(zone).date
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) } val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today } val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today }
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 } // What is left reads forwards; what is behind us reads backwards from
.thenComparator { a, b -> // today, nearest first.
// What is left reads forwards; what is behind us reads backwards val ordered = upcoming.sortedBy { it.event.dayStart(zone) } +
// from today, nearest first. past.sortedByDescending { it.event.dayStart(zone) }
if (upcoming(a)) {
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
} else {
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
}
}
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly } val (inDescriptions, dated) = ordered.partition { it.descriptionOnly }
return SearchResults( return SearchResults(
months = groupByMonth(dated, zone, todayStart), months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit }, inDescriptions = inDescriptions.map { it.hit },
@@ -126,33 +120,18 @@ object EventSearch {
zone: TimeZone, zone: TimeZone,
todayStart: Instant, todayStart: Instant,
): List<SearchMonth> { ): List<SearchMonth> {
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date) val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) }
return dated val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } }
.groupBy { monthKey(it.event.spanFirstDay(zone)) } val (behind, ahead) = groups.keys.partition { it < current }
.entries return (ahead.sorted() + behind.sortedDescending()).map { month ->
.sortedWith( SearchMonth(
compareBy<Map.Entry<Int, List<Matched>>> { year = month.year,
when { monthNumber = month.month.number,
it.key == currentKey -> 0 hits = groups.getValue(month).map { it.hit },
it.key > currentKey -> 1
else -> 2
}
}.thenComparator { a, b ->
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
},
) )
.map { (key, entries) -> }
SearchMonth(
year = key / 12,
monthNumber = key % 12 + 1,
hits = entries.map { it.hit },
)
}
} }
/** Year and month as one comparable number. */
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
private fun match( private fun match(
candidate: SearchCandidate, candidate: SearchCandidate,
tokens: List<String>, tokens: List<String>,
@@ -17,12 +17,15 @@ import kotlin.time.Instant
import kotlin.time.toJavaInstant import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant import kotlin.time.toKotlinInstant
/** [id] as a zone, or null when the tz database doesn't know it. */
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
/** /**
* The zone this form's wall-clock times mean, matching what the data layer * The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone]. * resolves them in at write time: the form's own pinned zone, else [deviceZone].
*/ */
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone timezone?.let(::zoneOrNull) ?: deviceZone
/** /**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats * This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
@@ -1,8 +1,5 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
@@ -35,17 +32,11 @@ fun buildInsertEventForm(
now: Instant, now: Instant,
): EventForm { ): EventForm {
val (start, end) = if (isAllDay) { val (start, end) = if (isAllDay) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
val startDate = beginMillis val startDate = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?: now.toLocalDateTime(zone).date ?: now.toLocalDateTime(zone).date
val endDate = endMillis val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } allDayFormTimes(startDate, endExclusive)
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else { } else {
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now) val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt } val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
@@ -62,7 +53,7 @@ fun buildInsertEventForm(
description = description.orEmpty(), description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
// some callers include. // some callers include.
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = rrule.normalizedRrule(),
) )
} }
@@ -1,5 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime
import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
@@ -57,24 +60,13 @@ fun parseSimpleRecurrence(
rrule: String, rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(), zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? { ): SimpleRecurrence? {
val parts = rrule.removePrefix("RRULE:").split(';') val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) { if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null return null
} }
val freq = when (parts["FREQ"]?.uppercase()) { val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) }
"DAILY" -> RecurrenceFreq.Daily ?: return null
"WEEKLY" -> RecurrenceFreq.Weekly
"MONTHLY" -> RecurrenceFreq.Monthly
"YEARLY" -> RecurrenceFreq.Yearly
else -> return null
}
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1 val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal // BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
@@ -124,20 +116,27 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
when (val e = end) { when (val e = end) {
RecurrenceEnd.Never -> Unit RecurrenceEnd.Never -> Unit
is RecurrenceEnd.Until -> { is RecurrenceEnd.Until -> {
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59)) append(";UNTIL=")
.toInstant(zone) append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic())
.toLocalDateTime(TimeZone.UTC)
append(
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.month.number, utc.day,
utc.hour, utc.minute, utc.second,
),
)
} }
is RecurrenceEnd.Count -> append(";COUNT=${e.times}") is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
} }
} }
/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */
internal class RrulePart(val raw: String) {
private val eq = raw.indexOf('=')
val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null
val value: String = if (eq > 0) raw.substring(eq + 1) else ""
}
/**
* [rule]'s non-blank parts, after an optional leading "RRULE:". What to do
* with a malformed one (reject, repair or skip) is the caller's call.
*/
internal fun rruleParts(rule: String): List<RrulePart> =
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf( internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO", DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU", DayOfWeek.TUESDAY to "TU",
@@ -165,11 +164,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
val key = part.substringBefore('=').trim().uppercase() val key = part.substringBefore('=').trim().uppercase()
key == "UNTIL" || key == "COUNT" key == "UNTIL" || key == "COUNT"
} }
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC) val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic()
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
until.year, until.month.number, until.day,
until.hour, until.minute, until.second,
)
return (kept + untilPart).joinToString(";") return (kept + untilPart).joinToString(";")
} }
@@ -178,20 +173,8 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so * forms are UTC (RFC 5545); the date is taken after converting into [zone] so
* a [toRRule]-rendered value round-trips to the day the user picked. * a [toRRule]-rendered value round-trips to the day the user picked.
*/ */
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching { private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? {
val date = LocalDate( val date = parseBasicDate(raw) ?: return null
raw.substring(0, 4).toInt(), if (raw.length < 15 || raw[8] != 'T') return date
raw.substring(4, 6).toInt(), return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date
raw.substring(6, 8).toInt(), }
)
if (raw.length >= 15 && raw[8] == 'T') {
val time = LocalTime(
raw.substring(9, 11).toInt(),
raw.substring(11, 13).toInt(),
raw.substring(13, 15).toInt(),
)
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
} else {
date
}
}.getOrNull()
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() } val parts = rruleParts(rrule)
if (parts.isEmpty()) return null if (parts.isEmpty()) return null
var freq = false
var weekly = false var weekly = false
var byDay = false
val rebuilt = parts.map { part -> val rebuilt = parts.map { part ->
val eq = part.indexOf('=') val value = part.value.trim()
if (eq <= 0) return null when (part.key) {
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
"FREQ" -> { "FREQ" -> {
freq = true
weekly = value.equals("WEEKLY", ignoreCase = true) weekly = value.equals("WEEKLY", ignoreCase = true)
part part.raw
} }
"BYDAY" -> { "BYDAY" -> {
byDay = true
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
} }
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part "INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
else -> return null else -> return null
} }
} }
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence. // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null if (!freq || (byDay && !weekly)) return null
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
return prefix + rebuilt.joinToString(";") return prefix + rebuilt.joinToString(";")
} }
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.calendula.domain.ics
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
private val BASIC_DATE_TIME = LocalDateTime.Format {
date(LocalDate.Formats.ISO_BASIC)
char('T')
hour()
minute()
second()
}
/** `yyyyMMdd`. */
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
/** `yyyyMMddTHHmmss`, floating. */
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
/**
* The date in the first eight characters of a basic-format value; whatever
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
*/
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = parseBasicDate(s) ?: return null
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
@@ -2,19 +2,17 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant import kotlin.time.Instant
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
private const val DAY_MILLIS = 86_400_000L
private const val MINUTES_PER_DAY = 1_440 private const val MINUTES_PER_DAY = 1_440
/** /**
@@ -318,13 +316,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
dtEnd: IcsDateTime?, dtEnd: IcsDateTime?,
duration: String?, duration: String?,
): Instant { ): Instant {
val explicit = dtEnd?.instant ?: duration?.let { val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds }
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
if (!start.isAllDay) return explicit ?: start.instant if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1) return explicit?.takeIf { it > start.instant } ?: (start.instant + 1.days)
} }
/** /**
@@ -343,17 +337,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
*/ */
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> { private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList() if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone val startZone = zoneOrNull(start.zoneId) ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time val startTime = start.instant.toLocalDateTime(startZone).time
return lines return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } } .flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) -> .mapNotNull { (line, token) ->
when { when {
token.isEmpty() -> null token.isEmpty() -> null
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode) start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic()
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp) token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic()
else -> parseBasicDate(token) else -> parseBasicDate(token)
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) } ?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() }
} }
} }
.distinct() .distinct()
@@ -373,7 +367,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when { val zone = when {
token.endsWith("Z") -> TimeZone.UTC token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() } else -> line.params["TZID"]?.let(::zoneOrNull)
?: startZone ?: startZone
} }
return ldt.toInstant(zone) return ldt.toInstant(zone)
@@ -409,7 +403,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC") if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"] val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } } val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone) if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone) val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId) return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
@@ -421,16 +415,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) = fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true) name == "BEGIN" && value.trim().equals(component, true)
fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun dayCode(date: LocalDate): String =
"%04d%02d%02d".format(date.year, date.month.number, date.day)
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
}
/** /**
* A single-valued `CATEGORIES`, which is what the Fossify family writes * A single-valued `CATEGORIES`, which is what the Fossify family writes
* for the source calendar's name. A multi-item list is a tag list and * for the source calendar's name. A multi-item list is a tag list and
@@ -449,59 +433,26 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
.takeIf { it.isNotEmpty() && !it.equals("null", true) } .takeIf { it.isNotEmpty() && !it.equals("null", true) }
} }
/** Index of the matching `END:<component>` at/after [from], or list end. */
/** /**
* Where an unterminated component's body has to stop: the next top-level * Where an unterminated component's body has to stop: the next top-level
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A * `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary. * nested `VALARM` is part of the body and is not a boundary.
*/ */
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int { fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int =
var i = from (from until end).firstOrNull { i ->
while (i < end) {
val line = parseContentLine(lines[i]) val line = parseContentLine(lines[i])
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) { line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))
return i } ?: end
}
i++
}
return end
}
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int { /** Index of the matching `END:<component>` at/after [from], or list end. */
var i = from fun indexOfEnd(lines: List<String>, from: Int, component: String): Int =
while (i < lines.size) { (from until lines.size).firstOrNull { i ->
val line = parseContentLine(lines[i]) val line = parseContentLine(lines[i])
if (line != null && line.name == "END" && line != null && line.name == "END" && line.value.trim().equals(component, true)
line.value.trim().equals(component, true) } ?: lines.size
) {
return i
}
i++
}
return lines.size
}
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) { fun mapIcsStatus(value: String): EventStatus =
"TENTATIVE" -> EventStatus.Tentative EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) }
"CANCELLED" -> EventStatus.Cancelled ?: EventStatus.Confirmed
else -> EventStatus.Confirmed
}
fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = LocalDate(
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
)
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
} }
} }
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.domain.ics package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.rruleParts
private val VALID_FREQ = setOf( private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
) )
@@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
return kept.takeIf { it.isNotEmpty() }?.joinToString(",") return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
} }
for (part in rule.split(';')) { // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part // being dropped, so it falls through and marks the rule repaired.
// being dropped, so it falls through and marks the rule repaired. for (part in rruleParts(raw.orEmpty().trim())) {
if (part.isBlank()) continue val key = part.key?.trim().orEmpty()
val key = part.substringBefore('=', "").trim().uppercase() val value = part.value.trim()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
val kept = when { val kept = when {
key.isEmpty() || value.isEmpty() -> null key.isEmpty() || value.isEmpty() -> null
key == "FREQ" -> key == "FREQ" ->
@@ -2,9 +2,8 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDateTime import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -41,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) { private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
add("BEGIN:VEVENT") add("BEGIN:VEVENT")
add("UID:${event.uid}") add("UID:${event.uid}")
add("DTSTAMP:${utcStamp(dtStamp)}") add("DTSTAMP:${dtStamp.toUtcBasic()}")
add("SUMMARY:${escapeText(event.summary)}") add("SUMMARY:${escapeText(event.summary)}")
appendTimes(event) appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() } event.recurrenceRule?.takeIf { it.isNotBlank() }
@@ -63,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendTimes(event: IcsEvent) = when { private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
event.isAllDay -> { event.isAllDay -> {
add("DTSTART;VALUE=DATE:${utcDate(event.start)}") add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
add("DTEND;VALUE=DATE:${utcDate(event.end)}") add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
} }
// Recurring: anchor to wall-clock in the event's own zone. // Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> { event.recurrenceRule?.isNotBlank() == true -> {
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull() val zone = zoneOrNull(event.zoneId)
if (zone != null) { if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}") add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}") add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}")
} else { } else {
// Unknown zone id → fall back to plain UTC instants. // Unknown zone id → fall back to plain UTC instants.
add("DTSTART:${utcStamp(event.start)}") add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${utcStamp(event.end)}") add("DTEND:${event.end.toUtcBasic()}")
} }
} }
else -> { else -> {
add("DTSTART:${utcStamp(event.start)}") add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${utcStamp(event.end)}") add("DTEND:${event.end.toUtcBasic()}")
} }
} }
@@ -120,20 +119,5 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
// A lead time of 0 fires at start (PT0M); anything positive is "before". // A lead time of 0 fires at start (PT0M); anything positive is "before".
fun triggerValue(minutes: Int): String = fun triggerValue(minutes: Int): String =
if (minutes <= 0) "PT0M" else "-PT${minutes}M" if (minutes <= 0) "PT0M" else "-PT${minutes}M"
fun utcStamp(instant: Instant): String =
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
fun localStamp(instant: Instant, zone: TimeZone): String =
basic(instant.toLocalDateTime(zone))
fun utcDate(instant: Instant): String {
val dt = instant.toLocalDateTime(TimeZone.UTC)
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
}
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
)
} }
} }
@@ -1,11 +1,9 @@
package de.jeanlucmakiola.calendula.domain.ics package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime import de.jeanlucmakiola.calendula.domain.allDayFormTimes
import de.jeanlucmakiola.calendula.domain.normalizedRrule
import de.jeanlucmakiola.calendula.domain.toFormTime import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -23,10 +21,7 @@ import kotlinx.datetime.toLocalDateTime
*/ */
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) { val (start, end) = if (isAllDay) {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else { } else {
this.start.toFormTime(zone) to this.end.toFormTime(zone) this.start.toFormTime(zone) to this.end.toFormTime(zone)
} }
@@ -40,6 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(), description = description.orEmpty(),
reminders = semanticReminderMinutes(), reminders = semanticReminderMinutes(),
availability = availability, availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = recurrenceRule.normalizedRrule(),
) )
} }
@@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.yearMonth
/** /**
* How far ahead the agenda (screen or widget) shows events, starting at today. * How far ahead the agenda (screen or widget) shows events, starting at today.
@@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
DAYS_PER_WEEK - sinceWeekStart DAYS_PER_WEEK - sinceWeekStart
} }
AgendaRange.ThisMonth -> { AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth() val daysInMonth = anchor.yearMonth.numberOfDays
daysInMonth - anchor.day + 1 daysInMonth - anchor.day + 1
} }
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
@@ -105,8 +106,8 @@ fun agendaRangeWindowSummary(
locale: Locale, locale: Locale,
monthAsSpan: Boolean = false, monthAsSpan: Boolean = false,
): String { ): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) val javaStart = start.toJavaLocalDate()
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) val javaEnd = end.toJavaLocalDate()
val dayMonth = localizedDateFormatter(locale, "dMMM") val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy") val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when { return when {
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -9,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
@@ -30,22 +34,25 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toJavaLocalDate
import kotlin.time.Instant
import java.util.Locale import java.util.Locale
// The agenda's row vocabulary, split out of AgendaScreen so the month view's // The agenda's row vocabulary, split out of AgendaScreen so the month view's
@@ -176,34 +183,74 @@ internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZ
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> stringResource( is AgendaTimeLabel.Starts -> stringResource(
R.string.agenda_span_starts, R.string.agenda_span_starts,
formatTime(label.start, zone, is24Hour, locale), formatTimeOfDay(label.start, zone, is24Hour, locale),
) )
is AgendaTimeLabel.Ends -> stringResource( is AgendaTimeLabel.Ends -> stringResource(
R.string.agenda_span_ends, R.string.agenda_span_ends,
formatTime(label.end, zone, is24Hour, locale), formatTimeOfDay(label.end, zone, is24Hour, locale),
) )
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " +
formatTime(label.end, zone, is24Hour, locale) formatTimeOfDay(label.end, zone, is24Hour, locale)
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(
instant: Instant,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String { private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = date.toJavaLocalDate()
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java) return localizedDateFormatter(locale, "EEEdMMMy").format(java)
} }
/**
* The agenda's day-by-day list body: a sticky header per day, its events or a
* placeholder row for an event-less day, then a gap. Shared by the Agenda screen
* and the month view's split pane so both lists read the same.
*/
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.agendaDays(
days: List<AgendaDay>,
today: LocalDate,
zone: TimeZone,
@StringRes emptyText: Int,
onEmptyClick: (LocalDate) -> Unit,
dimmed: (EventInstance, LocalDate) -> Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(emptyText),
onClick = { onEmptyClick(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimmed(event, day.date),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
@@ -10,9 +10,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -26,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
@@ -66,11 +62,9 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -379,7 +373,6 @@ internal fun AgendaContent(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AgendaList( private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
@@ -444,39 +437,17 @@ private fun AgendaList(
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
days.forEach { day -> agendaDays(
stickyHeader(key = "header-${day.date}") { days = days,
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) today = today,
} zone = zone,
if (day.events.isEmpty()) { // An anchored, event-less today (#35) — "nothing left today".
// An anchored, event-less today (#35) — "nothing left today". emptyText = R.string.agenda_no_more_today,
item(key = "placeholder-${day.date}") { onEmptyClick = onOpenDay,
AgendaEmptyDayRow( dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) },
text = stringResource(R.string.agenda_no_more_today), onOpenDay = onOpenDay,
onClick = { onOpenDay(day.date) }, onEventClick = onEventClick,
) )
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.isPastOn(day.date, now, zone),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
} }
} }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.dayRange
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -31,11 +32,8 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -243,8 +241,5 @@ class AgendaViewModel @Inject constructor(
internal const val PAST_CHUNK_DAYS = 30 internal const val PAST_CHUNK_DAYS = 30
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */ /** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> { internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
val from = anchor.atStartOfDayIn(zone) dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -53,6 +53,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -317,7 +318,7 @@ private fun ExportCalendarPicker(
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected val isSelected = calendar.id in selected
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
summary = calendar.description, summary = calendar.description,
position = positionOf(index, calendars.size), position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
@@ -40,7 +40,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -73,6 +72,7 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.GroupedSectionHeader
@@ -238,21 +239,10 @@ private fun CalendarsList(
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall) GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
} else { } else {
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem CalendarManagerRow(
GroupedRow( calendar = calendar,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, last = index == local.lastIndex,
summary = calendarRowSummary(calendar), onSetVisible = onSetVisible,
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
},
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
) )
} }
@@ -323,27 +313,10 @@ private fun CalendarsList(
// bottom, dimmed and switchless. // bottom, dimmed and switchless.
val ordered = cals.orderedForManager() val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar -> ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced CalendarManagerRow(
GroupedRow( calendar = calendar,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, last = index == ordered.lastIndex,
summary = calendarRowSummary(calendar), onSetVisible = onSetVisible,
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
},
)
}
} else {
null
},
) )
} }
} }
@@ -682,17 +655,37 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and * `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description. * stops its reminders. Carries its own content description.
*/ */
/**
* One calendar in a group: dimmed while switched off or not syncing, with the
* visibility switch only where it can change anything.
*/
@Composable @Composable
private fun EnableSwitch( private fun CalendarManagerRow(
calendarName: String, calendar: CalendarSource,
enabled: Boolean, last: Boolean,
onToggle: (Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
onClick: (() -> Unit)? = null,
) { ) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName) val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
Switch( GroupedRow(
checked = enabled, title = calendar.displayName.orUnnamed(),
onCheckedChange = onToggle, summary = calendarRowSummary(calendar),
modifier = Modifier.semantics { contentDescription = label }, position = if (last) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
CalendarVisibilitySwitch(
calendarName = calendar.displayName.orUnnamed(),
enabled = calendar.isVisibleInSystem,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
}
} else {
null
},
onClick = onClick,
) )
} }
@@ -57,7 +57,7 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
*/ */
@Composable @Composable
fun accountGroupTitle(group: CalendarAccountGroup): String { fun accountGroupTitle(group: CalendarAccountGroup): String {
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) } val label = group.label.orUnnamed()
return if (!group.ambiguous) { return if (!group.ambiguous) {
label label
} else { } else {
@@ -114,7 +114,7 @@ private fun CalendarPickerGroup(
) )
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
checked = calendar.id == selectedId, checked = calendar.id == selectedId,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.R
/** /**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in * The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
@@ -55,3 +57,7 @@ fun rememberTrimmedLineHeight(style: TextStyle): Dp {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() } with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
} }
} }
/** A calendar or account name, or the "unnamed" label when it is blank. */
@Composable
fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) }
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
/** The per-row switch writing the device's own `Calendars.VISIBLE`, as Settings does (#75). */
@Composable
fun CalendarVisibilitySwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/** Instant range from the start of [first] through the last second of [last] in [zone]. */
internal fun dayRange(first: LocalDate, last: LocalDate, zone: TimeZone): ClosedRange<Instant> =
first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -10,6 +10,8 @@ import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.rruleParts
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
@@ -32,10 +34,7 @@ import java.util.Locale
*/ */
@Composable @Composable
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token -> val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap()
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase() val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
@@ -171,12 +170,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()):
.atOffset(ZoneOffset.UTC) .atOffset(ZoneOffset.UTC)
.atZoneSameInstant(zone) .atZoneSameInstant(zone)
.toLocalDate() .toLocalDate()
}.recoverCatching { }.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate()
val digits = value.takeWhile { it.isDigit() }
LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
}.getOrNull()
} }
@@ -9,6 +9,7 @@ import kotlin.time.Instant
import kotlin.time.toJavaInstant import kotlin.time.toJavaInstant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toLocalDateTime
/** /**
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"), * The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
@@ -37,6 +38,12 @@ fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale):
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59)) LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
.format(timeOfDayFormatter(is24Hour, locale)) .format(timeOfDayFormatter(is24Hour, locale))
/** [instant]'s wall-clock time in [zone], as [formatTimeOfDay] renders it. */
fun formatTimeOfDay(instant: Instant, zone: TimeZone, is24Hour: Boolean, locale: Locale): String {
val time = instant.toLocalDateTime(zone).time
return formatTimeOfDay(time.hour, time.minute, is24Hour, locale)
}
/** /**
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value * Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
* 1440 renders as "24:00" in 24h (its established reading) and as midnight * 1440 renders as "24:00" in 24h (its established reading) and as midnight
@@ -89,6 +89,8 @@ import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -420,7 +422,7 @@ private fun EventDetailContent(
// — which a cross-zone event easily does — carries the whole span // — which a cross-zone event easily does — carries the whole span
// in the primary instead, so fall back to it. If the zone can't be // in the primary instead, so fall back to it. If the zone can't be
// read back at all, drop to just the abbreviation + offset. // read back at all, drop to just the abbreviation + offset.
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull() val originalTime = zoneOrNull(zoneOption.id)
?.let { rememberWhen(instance, it, locale) } ?.let { rememberWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary } ?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
@@ -445,7 +447,7 @@ private fun EventDetailContent(
iconContentDescription = stringResource(R.string.event_detail_calendar), iconContentDescription = stringResource(R.string.event_detail_calendar),
) { ) {
Text( Text(
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) } text = state.calendarName?.orUnnamed()
?: stringResource(R.string.event_detail_calendar_unknown), ?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
@@ -33,6 +33,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -85,11 +88,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -832,7 +833,7 @@ private fun EventEditContent(
text = stringResource( text = stringResource(
R.string.event_edit_managed_hint, R.string.event_edit_managed_hint,
selectedCalendar?.displayName selectedCalendar?.displayName
?.ifBlank { stringResource(R.string.calendar_unnamed) } ?.orUnnamed()
.orEmpty(), .orEmpty(),
), ),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -939,7 +940,7 @@ private fun EventEditContent(
) { ) {
Text( Text(
text = selectedCalendar?.displayName text = selectedCalendar?.displayName
?.ifBlank { stringResource(R.string.calendar_unnamed) } ?.orUnnamed()
?: stringResource(R.string.event_edit_error_no_calendar), ?: stringResource(R.string.event_edit_error_no_calendar),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = if (selectedCalendar == null) { color = if (selectedCalendar == null) {
@@ -20,8 +20,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.sourceAppName import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
@@ -43,6 +45,7 @@ fun CalendarFilterList(
is FilterUiState.Failure -> FilterMessage(s.reason, modifier) is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
is FilterUiState.Success -> FilterList( is FilterUiState.Success -> FilterList(
groups = s.groups, groups = s.groups,
hidden = s.hidden,
onSetVisible = viewModel::setVisible, onSetVisible = viewModel::setVisible,
modifier = modifier, modifier = modifier,
) )
@@ -56,23 +59,15 @@ fun CalendarFilterList(
*/ */
@Composable @Composable
private fun FilterList( private fun FilterList(
groups: List<AccountGroup>, groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
onSetVisible: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group -> groups.forEach { group ->
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
Text( Text(
text = if (group.ambiguous) { text = accountGroupTitle(group),
stringResource(
R.string.calendars_account_from_source,
account,
sourceAppName(group.accountType),
)
} else {
account
},
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding( modifier = Modifier.padding(
@@ -83,18 +78,19 @@ private fun FilterList(
), ),
) )
group.calendars.forEachIndexed { index, cal -> group.calendars.forEachIndexed { index, cal ->
val visible = cal.id !in hidden
GroupedRow( GroupedRow(
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = cal.displayName.orUnnamed(),
position = positionOf(index, group.calendars.size), position = positionOf(index, group.calendars.size),
minHeight = 56.dp, minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) }, leading = { CalendarColorChip(cal.color) },
trailing = { trailing = {
Checkbox( Checkbox(
checked = cal.visible, checked = visible,
onCheckedChange = { onSetVisible(cal.id, it) }, onCheckedChange = { onSetVisible(cal.id, it) },
) )
}, },
onClick = { onSetVisible(cal.id, !cal.visible) }, onClick = { onSetVisible(cal.id, !visible) },
) )
} }
} }
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.filter package de.jeanlucmakiola.calendula.ui.filter
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
/** /**
* State for the calendar-filter sheet (M3). The user toggles per-calendar * State for the calendar-filter sheet (M3). The user toggles per-calendar
@@ -10,26 +11,9 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
sealed interface FilterUiState { sealed interface FilterUiState {
data object Loading : FilterUiState data object Loading : FilterUiState
data class Failure(val reason: FailureReason) : FilterUiState data class Failure(val reason: FailureReason) : FilterUiState
data class Success(val groups: List<AccountGroup>) : FilterUiState /** A calendar shows when its id is *not* in [hidden]. */
data class Success(
val groups: List<CalendarAccountGroup>,
val hidden: Set<Long>,
) : FilterUiState
} }
/**
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
*
* [accountType] and [ambiguous] carry what the header needs to tell two
* same-named accounts from different apps apart (#77); the label itself is
* built in the UI layer, which is where the source app's name can be looked up.
*/
data class AccountGroup(
val account: String,
val accountType: String,
val ambiguous: Boolean,
val calendars: List<CalendarRow>,
)
data class CalendarRow(
val id: Long,
val displayName: String,
val color: Int,
val visible: Boolean,
)
@@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
@@ -39,7 +38,7 @@ class FilterViewModel @Inject constructor(
if (failure != null) { if (failure != null) {
FilterUiState.Failure(failure) FilterUiState.Failure(failure)
} else { } else {
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden)) FilterUiState.Success(enabled.groupByAccount(), hidden)
} }
} }
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -59,28 +58,3 @@ class FilterViewModel @Inject constructor(
} }
} }
/**
* Group calendars under their owning account — by name *and* type, so two
* accounts that merely share a name stay apart (#77) — preserving the
* provider's order within each group and ordering groups by first appearance.
* A calendar is "visible" when its id is *not* in [hidden].
*/
internal fun groupCalendarsForFilter(
calendars: List<CalendarSource>,
hidden: Set<Long>,
): List<AccountGroup> =
calendars.groupByAccount().map { group ->
AccountGroup(
account = group.label,
accountType = group.accountType,
ambiguous = group.ambiguous,
calendars = group.calendars.map { c ->
CalendarRow(
id = c.id,
displayName = c.displayName,
color = c.color,
visible = c.id !in hidden,
)
},
)
}
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
/** /**
@@ -23,8 +22,7 @@ internal fun monthChipTime(
locale: Locale, locale: Locale,
): String? { ): String? {
if (event.isAllDay || continuesLeft) return null if (event.isAllDay || continuesLeft) return null
val start = event.start.toLocalDateTime(zone).time return formatTimeOfDay(event.start, zone, is24Hour, locale)
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
} }
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */ /** The narrowest chip that may show a time — the text it needs, plus its chrome. */
@@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody import de.jeanlucmakiola.calendula.ui.common.ScreenBody
@@ -66,7 +68,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -191,10 +192,13 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toJavaMonth
import kotlinx.datetime.yearMonth
private val LocalTodayPulse = staticCompositionLocalOf { 0 } private val LocalTodayPulse = staticCompositionLocalOf { 0 }
@@ -343,7 +347,7 @@ fun MonthScreen(
if (dense) { if (dense) {
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY) .plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month) midweek.yearMonth
} else { } else {
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
} }
@@ -392,7 +396,7 @@ fun MonthScreen(
} }
} }
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) val isOnCurrentMonth = titleMonth == today.yearMonth
var splitExpanded by rememberSaveable(viewStyle) { var splitExpanded by rememberSaveable(viewStyle) {
mutableStateOf(viewStyle == MonthViewStyle.Paged) mutableStateOf(viewStyle == MonthViewStyle.Paged)
} }
@@ -421,7 +425,7 @@ fun MonthScreen(
val jumpToToday: () -> Unit = { val jumpToToday: () -> Unit = {
todayPulse++ todayPulse++
if (scrolling) { if (scrolling) {
val targetMonth = YearMonth(today.year, today.month) val targetMonth = today.yearMonth
pendingScrollMonth = targetMonth pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId val requestId = ++scrollRequestId
viewModel.goToToday() viewModel.goToToday()
@@ -439,13 +443,13 @@ fun MonthScreen(
} }
} else { } else {
viewModel.goToToday() viewModel.goToToday()
jumpToMonth(YearMonth(today.year, today.month)) jumpToMonth(today.yearMonth)
} }
} }
// Drawer jump-to-date. // Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target -> val jumpToDate: (LocalDate) -> Unit = { target ->
if (scrolling) { if (scrolling) {
val targetMonth = YearMonth(target.year, target.month) val targetMonth = target.yearMonth
pendingScrollMonth = targetMonth pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId val requestId = ++scrollRequestId
viewModel.goToDate(target) viewModel.goToDate(target)
@@ -463,7 +467,7 @@ fun MonthScreen(
} }
} else { } else {
viewModel.goToDate(target) viewModel.goToDate(target)
jumpToMonth(YearMonth(target.year, target.month)) jumpToMonth(target.yearMonth)
} }
} }
@@ -547,7 +551,7 @@ fun MonthScreen(
// still settling would land and take the tap back. // still settling would land and take the tap back.
onSelectDay = { date -> onSelectDay = { date ->
viewModel.selectDate(date) viewModel.selectDate(date)
jumpToMonth(YearMonth(date.year, date.month)) jumpToMonth(date.yearMonth)
}, },
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -720,7 +724,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter())) if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow -> days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) val javaDow = dow.toJavaDayOfWeek()
Text( Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -939,7 +943,7 @@ internal fun ContinuousMonthGrid(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val monthCount = remember { continuousMonthCount() } val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } val todayMonth = remember(state.today) { state.today.yearMonth }
val timeChipWidth = rememberMonthTimeChipWidth() val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -1837,7 +1841,6 @@ private fun SplitDots(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SplitMonthAgendaPane( private fun SplitMonthAgendaPane(
state: MonthUiState.Success, state: MonthUiState.Success,
@@ -1855,33 +1858,16 @@ private fun SplitMonthAgendaPane(
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() } val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
days.forEach { day -> agendaDays(
val events = state.instancesByDay[day].orEmpty() days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
stickyHeader(key = "header-$day") { today = state.today,
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay) zone = state.zone,
} emptyText = R.string.month_split_no_events,
if (events.isEmpty()) { onEmptyClick = onCreateEvent,
item(key = "empty-$day") { dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
AgendaEmptyDayRow( onOpenDay = onOpenDay,
text = stringResource(R.string.month_split_no_events), onEventClick = onEventClick,
onClick = { onCreateEvent(day) }, )
)
}
} else {
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
AgendaEventRow(
event = event,
day = day,
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
}
} }
} }
@@ -2715,7 +2701,7 @@ private fun DayNumberCell(
private fun shortMonthName(date: LocalDate): String { private fun shortMonthName(date: LocalDate): String {
val locale = currentLocale() val locale = currentLocale()
return remember(date.month, locale) { return remember(date.month, locale) {
java.time.Month.of(date.month.ordinal + 1) date.month.toJavaMonth()
.getDisplayName(JavaTextStyle.SHORT, locale) .getDisplayName(JavaTextStyle.SHORT, locale)
} }
} }
@@ -2878,7 +2864,7 @@ private fun formatMonthTitle(
abbreviated: Boolean = false, abbreviated: Boolean = false,
): String = ): String =
formatCalendarTitle( formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1), date = ym.firstDay.toJavaLocalDate(),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL", skeleton = if (abbreviated) "LLL" else "LLLL",
@@ -14,10 +14,10 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.datetime.yearMonth
/** /**
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings * A live, scaled-down Month view in a given [MonthViewStyle], for the settings
@@ -51,7 +51,7 @@ internal fun MonthStylePreview(
state = sample.continuous, state = sample.continuous,
listState = rememberLazyListState( listState = rememberLazyListState(
initialFirstVisibleItemIndex = itemIndexForMonth( initialFirstVisibleItemIndex = itemIndexForMonth(
monthIndexOf(YearMonth(today.year, today.month)), monthIndexOf(today.yearMonth),
), ),
), ),
showWeekNumbers = false, showWeekNumbers = false,
@@ -109,7 +109,7 @@ private fun sampleMonthState(
weekStart: DayOfWeek, weekStart: DayOfWeek,
zone: TimeZone, zone: TimeZone,
): SampleMonth { ): SampleMonth {
val ym = YearMonth(today.year, today.month) val ym = today.yearMonth
val first = LocalDate(ym.year, ym.month, 1) val first = LocalDate(ym.year, ym.month, 1)
val events = sampleMonthEvents(first, today, zone) val events = sampleMonthEvents(first, today, zone)
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import de.jeanlucmakiola.calendula.ui.common.dayRange
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -39,15 +41,13 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
import kotlinx.datetime.yearMonth
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */ /** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
private const val MONTH_CACHE_SPAN = 2 private const val MONTH_CACHE_SPAN = 2
@@ -101,7 +101,7 @@ class MonthViewModel @Inject constructor(
} }
.stateWhileSubscribed(viewModelScope, todayDate) .stateWhileSubscribed(viewModelScope, todayDate)
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) private val _month = MutableStateFlow(todayDate.yearMonth)
val month: StateFlow<YearMonth> = _month val month: StateFlow<YearMonth> = _month
/** /**
@@ -182,11 +182,11 @@ class MonthViewModel @Inject constructor(
/** Last reported visible range, so a widening step can re-centre on it. */ /** Last reported visible range, so a widening step can re-centre on it. */
@Volatile @Volatile
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month)) private var visibleMonths = monthIndexOf(todayDate.yearMonth)
.let { it..it } .let { it..it }
private val _loadedMonths = MutableStateFlow( private val _loadedMonths = MutableStateFlow(
monthIndexOf(YearMonth(todayDate.year, todayDate.month)) monthIndexOf(todayDate.yearMonth)
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) }, .let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
) )
@@ -202,13 +202,11 @@ class MonthViewModel @Inject constructor(
// Widened to whole grid weeks at both ends: a month block still // Widened to whole grid weeks at both ends: a month block still
// has to know about an event that starts in the boundary week's // has to know about an event that starts in the boundary week's
// clipped-off days, or a bar running into the block would vanish. // clipped-off days, or a bar running into the block would vanish.
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws) val first = yearMonthForIndex(window.first).firstDay.startOfWeek(ws)
val last = firstOfMonth(yearMonthForIndex(window.last)) val last = yearMonthForIndex(window.last).lastDay
.plus(1, DateTimeUnit.MONTH) .startOfWeek(ws)
.minus(1, DateTimeUnit.DAY)
.startOfGridWeek(ws)
.plus(6, DateTimeUnit.DAY) .plus(6, DateTimeUnit.DAY)
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) val range = dayRange(first, last, zone)
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(range), repository.instances(range),
@@ -306,7 +304,7 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at. * silently list a date the grid isn't pointing at.
*/ */
fun selectDate(date: LocalDate) { fun selectDate(date: LocalDate) {
val target = YearMonth(date.year, date.month) val target = date.yearMonth
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date _selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target if (target != _month.value) _month.value = target
} }
@@ -321,12 +319,12 @@ class MonthViewModel @Inject constructor(
} else { } else {
val next = selected.plus(days, DateTimeUnit.DAY) val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next _selectedDate.value = next
_month.value = YearMonth(next.year, next.month) _month.value = next.yearMonth
} }
} }
fun goToToday() { fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month) _month.value = todayDate.yearMonth
_selectedDate.value = null _selectedDate.value = null
} }
@@ -337,8 +335,8 @@ class MonthViewModel @Inject constructor(
*/ */
private fun followToday(old: LocalDate, new: LocalDate) { private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return if (viewStyle.value.isScrolling) return
if (_month.value != YearMonth(old.year, old.month)) return if (_month.value != old.yearMonth) return
_month.value = YearMonth(new.year, new.month) _month.value = new.yearMonth
_selectedDate.value = null _selectedDate.value = null
} }
@@ -358,7 +356,7 @@ class MonthViewModel @Inject constructor(
/** Jump to the month containing [date] (drawer jump-to-date). */ /** Jump to the month containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) { fun goToDate(date: LocalDate) {
_month.value = YearMonth(date.year, date.month) _month.value = date.yearMonth
_selectedDate.value = date _selectedDate.value = date
} }
@@ -408,7 +406,7 @@ internal fun layoutMonthWeeks(
byDay: DayIndex, byDay: DayIndex,
zone: TimeZone, zone: TimeZone,
): List<MonthWeek> { ): List<MonthWeek> {
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) val gridStart = ym.firstDay.startOfWeek(weekStart)
val weekCount = weekRowsInMonth(ym, weekStart) val weekCount = weekRowsInMonth(ym, weekStart)
return (0 until weekCount).map { row -> return (0 until weekCount).map { row ->
@@ -529,12 +527,8 @@ internal fun monthGridRange(
weekStart: DayOfWeek, weekStart: DayOfWeek,
zone: TimeZone, zone: TimeZone,
): ClosedRange<Instant> { ): ClosedRange<Instant> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1) val gridStart = ym.firstDay.startOfWeek(weekStart)
val gridStart = firstOfMonth.startOfGridWeek(weekStart) return dayRange(gridStart, gridStart.plus(41, DateTimeUnit.DAY), zone)
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
val start = gridStart.atStartOfDayIn(zone)
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
return start..end
} }
/** /**
@@ -562,7 +556,7 @@ internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLea
* the first of the visible month. * the first of the visible month.
*/ */
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate = internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) { if (today.yearMonth == month) {
today today
} else { } else {
LocalDate(month.year, month.month, 1) LocalDate(month.year, month.month, 1)
@@ -630,8 +624,6 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
} }
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
/** /**
* The Dense style addresses *weeks* by absolute index instead: it has no month * The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it * blocks to key by, and a row's identity has to survive the months around it
@@ -646,12 +638,12 @@ private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31) private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 return base.daysUntil(date.startOfWeek(weekStart)) / 7
} }
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the Dense grid scrolls through (1900 → 2100). */ /** Total weeks the Dense grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int = internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
@@ -662,7 +654,7 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
// The row's midweek day: a boundary week is reported as whichever month owns // The row's midweek day: a boundary week is reported as whichever month owns
// most of it, the same rule the title uses. // most of it, the same rule the title uses.
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY) val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
return monthIndexOf(YearMonth(midweek.year, midweek.month)) return monthIndexOf(midweek.yearMonth)
} }
/** /**
@@ -671,19 +663,16 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
* laid out too, rather than flickering as placeholders. * laid out too, rather than flickering as placeholders.
*/ */
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange { internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1 val first = weekIndexOf(yearMonthForIndex(window.first).firstDay, weekStart) - 1
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last)) val lastMonthEnd = yearMonthForIndex(window.last).lastDay
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1) return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
} }
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
val first = firstOfMonth(ym) val first = ym.firstDay
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() return (leadOffset + ym.numberOfDays + 6) / 7
return (leadOffset + daysInMonth + 6) / 7
} }
/** /**
@@ -727,9 +716,3 @@ internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
countByDay = week.countByDay.filterKeys { it in own }, countByDay = week.countByDay.filterKeys { it in own },
) )
} }
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -8,20 +8,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
@@ -122,7 +121,7 @@ internal fun HiddenCalendarsStep(
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val off = !calendar.isVisibleInSystem val off = !calendar.isVisibleInSystem
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
summary = calendar.accountName.takeIf { it.isNotBlank() }, summary = calendar.accountName.takeIf { it.isNotBlank() },
position = when { position = when {
calendars.size == 1 -> Position.Alone calendars.size == 1 -> Position.Alone
@@ -133,8 +132,8 @@ internal fun HiddenCalendarsStep(
dimmed = off, dimmed = off,
trailing = if (calendar.hasVisibilitySwitch) { trailing = if (calendar.hasVisibilitySwitch) {
{ {
CalendarSwitch( CalendarVisibilitySwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, calendarName = calendar.displayName.orUnnamed(),
enabled = !off, enabled = !off,
onToggle = { on -> onSetVisible(calendar.id, on) }, onToggle = { on -> onSetVisible(calendar.id, on) },
) )
@@ -149,18 +148,3 @@ internal fun HiddenCalendarsStep(
Spacer(Modifier.height(OnboardingSpace.md)) Spacer(Modifier.height(OnboardingSpace.md))
} }
} }
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
@Composable
private fun CalendarSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -25,6 +25,7 @@ import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.durationLabel import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon import de.jeanlucmakiola.floret.components.ExpandIcon
import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedHint
@@ -129,7 +130,7 @@ internal fun EventFormScreen(
durationCalendars.forEachIndexed { index, calendar -> durationCalendars.forEachIndexed { index, calendar ->
val override = state.perCalendarEventDuration[calendar.id] val override = state.perCalendarEventDuration[calendar.id]
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
summary = override?.let { durationLabel(it) } summary = override?.let { durationLabel(it) }
?: stringResource( ?: stringResource(
R.string.settings_calendar_duration_inherits, R.string.settings_calendar_duration_inherits,
@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.android.hasPermission import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon import de.jeanlucmakiola.floret.components.ExpandIcon
@@ -172,7 +173,7 @@ internal fun NotificationsScreen(
// own section — link there instead. // own section — link there instead.
if (calendar.id in state.managedCalendarIds) { if (calendar.id in state.managedCalendarIds) {
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
summary = stringResource(R.string.settings_calendar_reminders_managed_hint), summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone, position = Position.Alone,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
@@ -189,7 +190,7 @@ internal fun NotificationsScreen(
} }
val expanded = calendar.id in expandedCalendars val expanded = calendar.id in expandedCalendars
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
position = if (expanded) Position.Top else Position.Alone, position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
trailing = { ExpandIcon(expanded) }, trailing = { ExpandIcon(expanded) },
@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.ReorderableRowHeight
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import kotlinx.datetime.toJavaDayOfWeek
/** /**
* Views: everything that changes how a calendar view reads, grouped by the view * Views: everything that changes how a calendar view reads, grouped by the view
@@ -504,7 +505,7 @@ private val WEEK_START_OPTIONS: List<WeekStartPref> =
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) { internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto) WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
// Localised weekday name, so no per-day string resource is needed. // Localised weekday name, so no per-day string resource is needed.
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1) is WeekStartPref.Day -> pref.day.toJavaDayOfWeek()
.getDisplayName(JavaTextStyle.FULL, currentLocale()) .getDisplayName(JavaTextStyle.FULL, currentLocale())
} }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -202,10 +202,12 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlinx.datetime.yearMonth
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ /** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp private val ALL_DAY_ROW_HEIGHT = 20.dp
@@ -1084,7 +1086,7 @@ private fun WeekDayHeader(
.fillMaxWidth() .fillMaxWidth()
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET), .padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
) { date -> ) { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) val javaDow = date.dayOfWeek.toJavaDayOfWeek()
val isToday = date == today val isToday = date == today
Column( Column(
modifier = Modifier modifier = Modifier
@@ -1958,7 +1960,7 @@ private fun formatWeekTitle(
abbreviated: Boolean = false, abbreviated: Boolean = false,
): String { ): String {
return formatCalendarTitle( return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), date = weekStart.yearMonth.firstDay.toJavaLocalDate(),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL", skeleton = if (abbreviated) "LLL" else "LLLL",
@@ -1974,7 +1976,7 @@ private fun formatDayTitle(
abbreviated: Boolean = false, abbreviated: Boolean = false,
): String = ): String =
formatCalendarTitle( formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), date = date.toJavaLocalDate(),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM", skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.dayRange
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -41,11 +42,9 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toKotlinUtcOffset import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toJavaZoneId import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import kotlin.time.Instant import kotlin.time.Instant
@@ -342,19 +341,9 @@ internal fun layoutAllDay(
private infix fun IntRange.overlaps(other: IntRange): Boolean = private infix fun IntRange.overlaps(other: IntRange): Boolean =
first <= other.last && other.first <= last first <= other.last && other.first <= last
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
/** Instant range covering the [count] days starting at [start]. */ /** Instant range covering the [count] days starting at [start]. */
internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> { internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> =
val from = start.atStartOfDayIn(zone) dayRange(start, start.plus(count - 1, DateTimeUnit.DAY), zone)
val to = start.plus(count - 1, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
/** /**
* This instant's wall-clock time as minutes past [day]'s midnight in [zone] — * This instant's wall-clock time as minutes past [day]'s midnight in [zone] —
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -794,11 +794,11 @@ class CalendarRepositoryImplTest {
} }
@Test @Test
fun `eventColorPalette delegates to the data source for the given calendar`( fun `eventColorPalette reads the given calendar's published palette`(
@TempDir tempDir: Path, @TempDir tempDir: Path,
) = runTest { ) = runTest {
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {
eventColorPaletteResult = { id -> publishedEventColorsResult = { id ->
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList() if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
} }
} }
@@ -929,7 +929,6 @@ class CalendarRepositoryImplTest {
val published = listOf(EventColorOption("1", 0xFF808080.toInt())) val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { published } publishedEventColorsResult = { published }
eventColorPaletteResult = { emptyList() }
} }
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined) val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
@@ -22,7 +22,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() } var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() } var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null } var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() } var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList() var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */ /** The [calendarIds] the last [exportableEvents] call received (null = all). */
@@ -80,8 +79,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query) override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? = override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
eventDetailResult(eventId) eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun publishedEventColors(calendarId: Long): List<EventColorOption> = override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
publishedEventColorsResult(calendarId) publishedEventColorsResult(calendarId)
override fun exportableEvents( override fun exportableEvents(
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.calendula.ui.filter package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class FilterGroupingTest { class AccountGroupsTest {
private fun cal( private fun cal(
id: Long, id: Long,
@@ -28,35 +28,18 @@ class FilterGroupingTest {
cal(3, "Shared", "team@dav"), cal(3, "Shared", "team@dav"),
) )
val groups = groupCalendarsForFilter(calendars, hidden = emptySet()) val groups = calendars.groupByAccount()
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder() assertThat(groups.map { it.label }).containsExactly("alice@dav", "team@dav").inOrder()
assertThat(groups[0].calendars.map { it.displayName }) assertThat(groups[0].calendars.map { it.displayName })
.containsExactly("Personal", "Work").inOrder() .containsExactly("Personal", "Work").inOrder()
assertThat(groups[1].calendars.map { it.id }).containsExactly(3L) assertThat(groups[1].calendars.map { it.id }).containsExactly(3L)
} }
@Test
fun `hidden ids mark calendars not visible`() {
val calendars = listOf(
cal(1, "Personal", "alice@dav"),
cal(2, "Work", "alice@dav"),
)
val groups = groupCalendarsForFilter(calendars, hidden = setOf(2L))
val rows = groups.single().calendars.associateBy { it.id }
assertThat(rows.getValue(1L).visible).isTrue()
assertThat(rows.getValue(2L).visible).isFalse()
}
@Test @Test
fun `blank account name falls back to type`() { fun `blank account name falls back to type`() {
val groups = groupCalendarsForFilter( val groups = listOf(cal(1, "Birthdays", account = "", type = "LOCAL")).groupByAccount()
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")), assertThat(groups.single().label).isEqualTo("LOCAL")
hidden = emptySet(),
)
assertThat(groups.single().account).isEqualTo("LOCAL")
} }
/** #77: same address, two apps — two accounts, and they must read as two. */ /** #77: same address, two apps — two accounts, and they must read as two. */
@@ -67,7 +50,7 @@ class FilterGroupingTest {
cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"), cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"),
) )
val groups = groupCalendarsForFilter(calendars, hidden = emptySet()) val groups = calendars.groupByAccount()
assertThat(groups).hasSize(2) assertThat(groups).hasSize(2)
assertThat(groups.map { it.accountType }) assertThat(groups.map { it.accountType })
@@ -78,10 +61,7 @@ class FilterGroupingTest {
@Test @Test
fun `one name under one type is not ambiguous`() { fun `one name under one type is not ambiguous`() {
val groups = groupCalendarsForFilter( val groups = listOf(cal(1, "Personal", "me@example.com")).groupByAccount()
listOf(cal(1, "Personal", "me@example.com")),
hidden = emptySet(),
)
assertThat(groups.single().ambiguous).isFalse() assertThat(groups.single().ambiguous).isFalse()
} }
@@ -89,13 +69,10 @@ class FilterGroupingTest {
/** Two different names from the same app are still two plain groups. */ /** Two different names from the same app are still two plain groups. */
@Test @Test
fun `different names under one type are not ambiguous`() { fun `different names under one type are not ambiguous`() {
val groups = groupCalendarsForFilter( val groups = listOf(
listOf( cal(1, "Personal", "alice@example.com"),
cal(1, "Personal", "alice@example.com"), cal(2, "Shared", "bob@example.com"),
cal(2, "Shared", "bob@example.com"), ).groupByAccount()
),
hidden = emptySet(),
)
assertThat(groups).hasSize(2) assertThat(groups).hasSize(2)
assertThat(groups.none { it.ambiguous }).isTrue() assertThat(groups.none { it.ambiguous }).isTrue()
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -120,8 +121,8 @@ class ContinuousMonthIndexTest {
val weeks = weekWindowFor(window, ws) val weeks = weekWindowFor(window, ws)
window.forEach { index -> window.forEach { index ->
val ym = yearMonthForIndex(index) val ym = yearMonthForIndex(index)
val first = weekIndexOf(firstOfMonth(ym), ws) val first = weekIndexOf(ym.firstDay, ws)
val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws) val last = weekIndexOf(yearMonthForIndex(index + 1).firstDay.minus(1, DateTimeUnit.DAY), ws)
assertThat(weeks.contains(first)).isTrue() assertThat(weeks.contains(first)).isTrue()
assertThat(weeks.contains(last)).isTrue() assertThat(weeks.contains(last)).isTrue()
} }
@@ -152,7 +153,7 @@ class ContinuousMonthIndexTest {
val wed = LocalDate(2026, 6, 10) val wed = LocalDate(2026, 6, 10)
DayOfWeek.entries.forEach { ws -> DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws) val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfWeek(ws))
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(index).isLessThan(continuousWeekCount(ws)) assertThat(index).isLessThan(continuousWeekCount(ws))
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
@@ -161,8 +162,8 @@ class ContinuousMonthIndexTest {
} }
@Test @Test
fun `firstOfMonth is the 1st`() { fun `firstDay is the 1st`() {
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) assertThat(jun26.firstDay).isEqualTo(LocalDate(2026, 6, 1))
} }
@Test @Test
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.allDayEvent import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.domain.EventResponse import de.jeanlucmakiola.calendula.domain.EventResponse
@@ -51,12 +52,12 @@ class MonthLayoutTest {
) = allDayEvent(from, toInclusive, id = id, title = title, color = 0xFF445566.toInt()) ) = allDayEvent(from, toInclusive, id = id, title = title, color = 0xFF445566.toInt())
@Test @Test
fun `startOfGridWeek snaps back to the configured week start`() { fun `startOfWeek snaps back to the configured week start`() {
val wed = LocalDate(2026, 6, 10) val wed = LocalDate(2026, 6, 10)
assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) assertThat(jun8.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
// A Sunday-anchored week containing the 10th starts on the 7th. // A Sunday-anchored week containing the 10th starts on the 7th.
assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7)) assertThat(wed.startOfWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
} }
@Test @Test
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.testEvent import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek