diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index d938951..adcfaeb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -38,7 +38,6 @@ import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import de.jeanlucmakiola.calendula.domain.toEditForm import kotlinx.datetime.TimeZone import kotlinx.datetime.toJavaLocalDate -import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZoneOffset @@ -76,21 +75,13 @@ interface CalendarDataSource { /** * The event-colour palette the calendar's account publishes - * (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped, - * thinned to visually distinct swatches when oversized (CalDAV adapters - * publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker]. + * (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order. * Empty when the account exposes no palette (most local calendars, some * CalDAV) — the signal that a custom colour can only be written as a raw - * `EVENT_COLOR`, which a synced calendar may drop on its next sync. - */ - fun eventColorPalette(calendarId: Long): List - - /** - * 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]. + * `EVENT_COLOR`, which a synced calendar may drop on its next sync. The + * picker shows it curated ([curatedForPicker]); matching a colour that came + * from outside the account runs against this full set, since those keys are + * all the calendar accepts. */ fun publishedEventColors(calendarId: Long): List @@ -359,7 +350,7 @@ class AndroidCalendarDataSource @Inject constructor( CalendarProjection.COLUMNS, null, null, 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() - 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 values = ContentValues().apply { put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) @@ -392,11 +396,9 @@ class AndroidCalendarDataSource @Inject constructor( ) put(CalendarContract.Calendars.VISIBLE, 1) put(CalendarContract.Calendars.SYNC_EVENTS, 1) - putDescription(description) + extra() } - val uri = resolver.insert(localCalendarsUri(), values) - // No calendar name in the message — it can reach a crash report. - ?: throw WriteFailedException("create local calendar") + val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what) return ContentUris.parseId(uri) } @@ -463,30 +465,13 @@ class AndroidCalendarDataSource @Inject constructor( override fun canWriteCalendars(): Boolean = context.hasPermission(Manifest.permission.WRITE_CALENDAR) - override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { - val name = displayName.trim() - 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) + override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long = + insertLocalCalendar(displayName, color, "create managed calendar") { // The marker that identifies this as our special-dates calendar, // independent of the (user-editable) display name. CAL_SYNC1 already // holds the description, so the marker lives in CAL_SYNC2. put(MANAGED_MARKER_COLUMN, markerFor(type)) } - val uri = resolver.insert(localCalendarsUri(), values) - ?: throw WriteFailedException("create managed calendar") - return ContentUris.parseId(uri) - } override fun findManagedCalendars(): List = resolver.query( 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 // birthday reminder keeps their change. Encoded against the upcoming // occurrence, not the ancient DTSTART anchor (wrong timezone offset). - val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate() insertReminderRows( eventId, - managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes), + managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes), ) return eventId } override fun updateManagedFields(eventId: Long, values: Map) { if (values.isEmpty()) return - val rows = resolver.update( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), - values.toContentValues(), - null, null, - ) - if (rows == 0) throw WriteFailedException("update managed event id=$eventId") + updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId") } override fun applyManagedCalendarReminders( @@ -573,38 +552,21 @@ class AndroidCalendarDataSource @Inject constructor( allDayReminderTimeMinutes: Int, minutes: List, ) { - resolver.query( - CalendarContract.Events.CONTENT_URI, - arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART), - // Only our own mirror events, so a stray user event in this calendar - // isn't all-day-re-encoded or stripped of its own reminders. - "${CalendarContract.Events.CALENDAR_ID} = ? AND " + - "${CalendarContract.Events.DELETED} = 0 AND " + - "${CalendarContract.Events.UID_2445} LIKE ?", - arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"), - null, - )?.use { c -> - while (c.moveToNext()) { - val eventId = c.getLong(0) - // Managed events are all-day: DTSTART is a UTC midnight anchor. - val anchor = Instant.ofEpochMilli(c.getLong(1)) - .atZone(ZoneOffset.UTC).toLocalDate() - reconcileReminders( - eventId, - managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes), - ) - } + // Only our own mirror events, so a stray user event in this calendar + // isn't all-day-re-encoded or stripped of its own reminders. + queryManagedEvents(calendarId).forEach { event -> + // Managed events are all-day: DTSTART is a UTC midnight anchor. + reconcileReminders( + event.eventId, + managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes), + ) } } override fun instances(beginMillis: Long, endMillis: Long): List { ensureObserversRegistered() - val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { - ContentUris.appendId(this, beginMillis) - ContentUris.appendId(this, endMillis) - }.build() return resolver.query( - uri, + instancesUri(beginMillis, endMillis), InstanceProjection.COLUMNS, // Hide cancelled occurrences: "delete only this event" writes 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", null, CalendarContract.Instances.BEGIN + " ASC", - )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() + )?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList() } override fun searchEvents(query: String): List { @@ -721,13 +683,9 @@ class AndroidCalendarDataSource @Inject constructor( endMillis: Long, soonestFirst: Boolean, ): Pair? { - 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" return resolver.query( - uri, + instancesUri(beginMillis, endMillis), arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END), "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()), @@ -741,7 +699,7 @@ class AndroidCalendarDataSource @Inject constructor( val attendees = queryAttendees(eventId) val reminders = queryReminders(eventId) return resolver.query( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + eventUri(eventId), EventDetailProjection.COLUMNS, null, null, null, )?.use { c -> @@ -752,9 +710,6 @@ class AndroidCalendarDataSource @Inject constructor( } } - override fun eventColorPalette(calendarId: Long): List = - publishedEventColors(calendarId).curatedForPicker() - override fun publishedEventColors(calendarId: Long): List { val account = calendarAccount(calendarId) ?: return emptyList() return resolver.query( @@ -807,11 +762,10 @@ class AndroidCalendarDataSource @Inject constructor( null, CalendarContract.Events.DTSTART + " ASC", )?.use { c -> - c.mapAll { - val reader = CursorColumnReader(c) - val eventId = reader.getLong(EventExportProjection.IDX_ID) - val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID) - reader.toIcsEvent( + c.mapRows { + val eventId = getLong(EventExportProjection.IDX_ID) + val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID) + toIcsEvent( reminderMinutes = queryReminders(eventId).map { it.minutes }, calendarName = names[calendarId], allDayReminderTimeMinutes = allDayReminderTimeMinutes, @@ -846,7 +800,7 @@ class AndroidCalendarDataSource @Inject constructor( calendarId = calendarId, // Preserve the file's UID so a re-import dedups against it; mint one // 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, ) val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) @@ -859,8 +813,7 @@ class AndroidCalendarDataSource @Inject constructor( // (see [importedAllDayReminderDate]). val reminderDate = if (event.isAllDay) { importedAllDayReminderDate( - startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) - .atZone(ZoneOffset.UTC).toLocalDate(), + startDate = utcDate(event.start.toEpochMilliseconds()), recurrenceRule = event.recurrenceRule, today = LocalDate.now(zone), ) @@ -878,24 +831,7 @@ class AndroidCalendarDataSource @Inject constructor( } else { minutes } - val reminder = ContentValues().apply { - 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") - } + insertReminderRow(eventId, providerMinutes) } return eventId } @@ -951,18 +887,13 @@ class AndroidCalendarDataSource @Inject constructor( // back to a stable synthesised UID at export time (deriveIcsUid). val values = buildEventInsertValues( form, - uid = "${UUID.randomUUID()}@calendula", + uid = mintUid(), times = form.toWriteTimes(ZoneId.systemDefault()), - ).toContentValues().apply { - // A null colour just leaves both columns unset (the event inherits - // its calendar's colour), so only the key/raw cases are written. - when { - form.colorKey != null -> - 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) + ) + eventColorColumns(form.colorKey, form.color) + // A new row has nothing to clear: unset columns inherit the + // calendar's colour, so only the key/raw values are written. + .filterValues { it != null } + val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") val eventId = ContentUris.parseId(uri) seedReminders(eventId, form, allDayReminderTimeMinutes) @@ -983,16 +914,32 @@ class AndroidCalendarDataSource @Inject constructor( /** Insert one `METHOD_ALERT` reminder row per raw provider offset. */ private fun insertReminderRows(eventId: Long, encodedMinutes: List) { - encodedMinutes.forEach { minutes -> - val reminder = ContentValues().apply { - put(CalendarContract.Reminders.EVENT_ID, eventId) - put(CalendarContract.Reminders.MINUTES, minutes) - put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) - } - if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { - Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") - } + encodedMinutes.forEach { insertReminderRow(eventId, it) } + } + + /** + * 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 + * was created as failed — and an import retry would then skip it as a + * 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(), ) if (values.isNotEmpty()) { - val rows = resolver.update( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), - values.toContentValues(), - null, null, - ) - if (rows == 0) throw WriteFailedException("update event id=$eventId") + updateEventRow(eventId, values.toContentValues(), "update event id=$eventId") } // Untouched reminder sets are left alone so unrelated edits can't // 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") // 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. - val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula" + val uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid() val newEventId = resolver.insert( CalendarContract.Events.CONTENT_URI, 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. */ private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + eventUri(eventId), MoveMasterProjection.COLUMNS, null, null, null, )?.use { c -> @@ -1108,8 +1050,7 @@ class AndroidCalendarDataSource @Inject constructor( title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(), isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0, dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART), - dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND) - .takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) }, + dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND), duration = r.getString(MoveMasterProjection.IDX_DURATION), rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }, rdate = r.getString(MoveMasterProjection.IDX_RDATE), @@ -1117,8 +1058,7 @@ class AndroidCalendarDataSource @Inject constructor( timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE), availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY), accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL), - status = r.getInt(MoveMasterProjection.IDX_STATUS) - .takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) }, + status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS), location = r.getString(MoveMasterProjection.IDX_LOCATION), description = r.getString(MoveMasterProjection.IDX_DESCRIPTION), ), @@ -1135,16 +1075,7 @@ class AndroidCalendarDataSource @Inject constructor( arrayOf(fromEventId.toString()), null, )?.use { c -> - while (c.moveToNext()) { - 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") - } - } + while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1)) } ?: Unit /** @@ -1189,22 +1120,8 @@ class AndroidCalendarDataSource @Inject constructor( .toDetachedOccurrence() detached += insertEvent(form, allDayReminderTimeMinutes) } - val row = querySeriesRow(toEventId) - val values = buildOccurrencesExdateValues( - 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") + val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis }) + updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId") } /** The series' exception rows (modified + cancelled), oldest occurrence first. */ @@ -1215,18 +1132,14 @@ class AndroidCalendarDataSource @Inject constructor( arrayOf(seriesEventId.toString()), CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC", )?.use { c -> - c.mapAll { - val r = CursorColumnReader(c) - val status = r.getInt(ExceptionProjection.IDX_STATUS) - .takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) } + c.mapRows { ExceptionRowSnapshot( - exceptionEventId = r.getLong(ExceptionProjection.IDX_ID), - originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME), - isCancelled = status == CalendarContract.Events.STATUS_CANCELED, - dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART), - dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND) - .takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) }, - duration = r.getString(ExceptionProjection.IDX_DURATION), + exceptionEventId = getLong(ExceptionProjection.IDX_ID), + originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME), + isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED, + dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART), + dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND), + duration = getString(ExceptionProjection.IDX_DURATION), ) } } ?: emptyList() @@ -1263,7 +1176,7 @@ class AndroidCalendarDataSource @Inject constructor( val exceptionId = ContentUris.parseId(uri) form.colorKey?.let { key -> resolver.update( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId), + eventUri(exceptionId), ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) }, null, null, ) @@ -1317,34 +1230,19 @@ class AndroidCalendarDataSource @Inject constructor( // a fresh UID — the detached row is a separate event now, and sharing the // parent's would collide with it in .ics restore dedup. 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 // exists. It catches the row disappearing under us, not an EXDATE the // provider's expansion fails to match. - val updatedRows = try { - resolver.update( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), - values.toContentValues(), null, null, + try { + updateEventRow( + eventId, + row.exdateValues(listOf(beginMillis)), + "exdate occurrence for edit, event id=$eventId begin=$beginMillis", ) } catch (t: Throwable) { rollBackDetached(detachedId) throw t } - if (updatedRows == 0) { - rollBackDetached(detachedId) - throw WriteFailedException( - "exdate occurrence for edit, event id=$eventId begin=$beginMillis", - ) - } return detachedId } @@ -1417,23 +1315,11 @@ class AndroidCalendarDataSource @Inject constructor( updated = updated, zone = ZoneId.systemDefault(), ) ?: return - val row = querySeriesRow(newEventId) - val values = ContentValues().apply { + val values = querySeriesRow(newEventId).timeColumns().apply { 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 { - val rows = resolver.update( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId), - values, null, null, - ) - if (rows == 0) { - throw WriteFailedException("carry exdate onto split series id=$newEventId") - } + updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId") } catch (t: Throwable) { runCatching { deleteEvent(newEventId) } throw t @@ -1461,29 +1347,18 @@ class AndroidCalendarDataSource @Inject constructor( */ private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) { requireNotNull(row.rrule) { "truncateSeries needs a recurring row" } - val values = ContentValues().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) + val values = row.timeColumns().apply { put( CalendarContract.Events.RRULE, rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)), ) } - val rows = resolver.update( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), - values, - null, null, - ) - if (rows == 0) { - throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis") - } + updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis") } /** The series anchor: every time-related column of the Events row. */ private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + eventUri(eventId), arrayOf( CalendarContract.Events.DTSTART, CalendarContract.Events.RRULE, @@ -1531,6 +1406,30 @@ class AndroidCalendarDataSource @Inject constructor( */ 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): Map = 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]. */ fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis( beginMillis = beginMillis, @@ -1556,20 +1455,10 @@ class AndroidCalendarDataSource @Inject constructor( arrayOf(eventId.toString(), minutes.toString()), ) } - (target - existing).forEach { minutes -> - val reminder = ContentValues().apply { - put(CalendarContract.Reminders.EVENT_ID, eventId) - put(CalendarContract.Reminders.MINUTES, minutes) - put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) - } - if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { - Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") - } - } + insertReminderRows(eventId, (target - existing).toList()) } - /** Normalised key an attendee is matched/deduped on (case-insensitive email). */ - private fun EventAttendee.key(): String = email.trim().lowercase() + private fun EventAttendee.key(): String = emailKey(email) /** Distinct guests by email; blanks dropped (we key and write on the address). */ private fun List.distinctByEmail(): List { @@ -1621,7 +1510,7 @@ class AndroidCalendarDataSource @Inject constructor( val relationship: Int, val type: Int, ) { - val key: String get() = email.trim().lowercase() + val key: String get() = emailKey(email) } private fun queryAttendeeRows(eventId: Long): List = resolver.query( @@ -1686,17 +1575,20 @@ class AndroidCalendarDataSource @Inject constructor( ) } } - target.filterNot { it.key() in editableKeys }.forEach { attendee -> - if (resolver.insert( - CalendarContract.Attendees.CONTENT_URI, - attendeeValues(eventId, attendee), - ) == null - ) { - Log.w(TAG, "Failed to attach a guest to event $eventId") - } - } + insertAttendees(eventId, target.filterNot { it.key() in editableKeys }) } + 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, what: String) = + updateEventRow(eventId, values.toContentValues(), what) + private fun Map.toContentValues(): ContentValues = ContentValues().also { cv -> forEach { (column, value) -> @@ -1713,7 +1605,7 @@ class AndroidCalendarDataSource @Inject constructor( override fun deleteEvent(eventId: Long) { val deleted = resolver.delete( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + eventUri(eventId), null, null, ) 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. // Calendula's own contact special-date calendars are local and hold // yearly series, so this path is reached in normal use. - 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, + updateEventRow( + eventId, + row.exdateValues(listOf(beginMillis)), + "exdate occurrence event id=$eventId begin=$beginMillis", ) - 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 } // A cancelled exception row hides exactly this occurrence; the sync @@ -1801,7 +1681,7 @@ class AndroidCalendarDataSource @Inject constructor( CalendarContract.Attendees.EVENT_ID + " = ?", arrayOf(eventId.toString()), null, - )?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList() + )?.use { c -> c.mapRows { toAttendee() } } ?: emptyList() private fun queryReminders(eventId: Long): List = resolver.query( CalendarContract.Reminders.CONTENT_URI, @@ -1809,19 +1689,15 @@ class AndroidCalendarDataSource @Inject constructor( CalendarContract.Reminders.EVENT_ID + " = ?", arrayOf(eventId.toString()), null, - )?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList() - - private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource() + )?.use { c -> c.mapRows { toReminder() } } ?: emptyList() /** Iterate every row and map; skips nothing. */ private inline fun Cursor.mapAll(mapper: (Cursor) -> T): List = buildList { while (moveToNext()) add(mapper(this@mapAll)) } - /** Iterate every row and map; drops nulls. */ - private inline fun Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List = buildList { - while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add) - } + /** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */ + private fun mintUid(): String = "${UUID.randomUUID()}@calendula" /** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */ private fun markerFor(type: SpecialDateType): String = when (type) { @@ -1837,6 +1713,9 @@ class AndroidCalendarDataSource @Inject constructor( private companion object { 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 * together (by account) in the filter sheet and calendar manager. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index e923672..d140a75 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -29,7 +29,7 @@ interface CalendarRepository { /** * 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 77bdc39..27c5166 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.floret.di.IoDispatcher import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.curatedForPicker import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail @@ -125,8 +126,7 @@ class CalendarRepositoryImpl @Inject constructor( beginMillis = range.start.toEpochMillis(), endMillis = range.endInclusive.toEpochMillis(), ), - switchedOffCalendarIds = invisibleCalendarIds() + - prefs.pendingDisabledCalendarIds.first(), + switchedOffCalendarIds = switchedOffCalendarIds(), ) }, prefs.hiddenCalendarIds, @@ -151,6 +151,10 @@ class CalendarRepositoryImpl @Inject constructor( .filterNot { it.isVisibleInSystem } .mapTo(mutableSetOf()) { it.id } + /** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */ + private suspend fun switchedOffCalendarIds(): Set = + invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first() + private val calendarsLock = Mutex() private var cachedGeneration = -1L private var cachedPending: Set? = null @@ -184,15 +188,13 @@ class CalendarRepositoryImpl @Inject constructor( override suspend fun searchEvents(query: String): List = withContext(io) { if (query.isBlank()) return@withContext emptyList() - val excluded = prefs.hiddenCalendarIds.first() + - prefs.pendingDisabledCalendarIds.first() + - invisibleCalendarIds() + val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds() dataSource.searchEvents(query) .let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } } } override suspend fun eventColorPalette(calendarId: Long): List = - withContext(io) { dataSource.eventColorPalette(calendarId) } + withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() } override suspend fun createLocalCalendar( displayName: String, @@ -214,16 +216,15 @@ class CalendarRepositoryImpl @Inject constructor( override suspend fun setCalendarsVisible(ids: Collection, visible: Boolean) = write { - if (dataSource.canWriteCalendars()) { - ids.forEach { dataSource.setCalendarVisible(it, visible) } - // Nothing of ours is left waiting for the provider once the - // write lands (and switching one back on retires its entry). - prefs.removePendingDisabledCalendarIds(ids) - } else if (visible) { + val canWrite = dataSource.canWriteCalendars() + if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) } + // Nothing of ours is left waiting for the provider once the write + // lands, and switching one back on retires its entry. With read-only + // permission a switch-off still works, app-side, and the reconciler + // flushes it if WRITE_CALENDAR ever arrives. + if (canWrite || visible) { prefs.removePendingDisabledCalendarIds(ids) } else { - // Read-only permission: the switch still works, app-side, and - // the reconciler flushes it if WRITE_CALENDAR ever arrives. prefs.addPendingDisabledCalendarIds(ids) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt index 12286e3..3804f8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt @@ -1,6 +1,11 @@ package de.jeanlucmakiola.calendula.data.calendar 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 @@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader { override fun getInt(index: Int): Int = cursor.getInt(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 Cursor.mapRows(map: ColumnReader.() -> T): List { + val reader = CursorColumnReader(this) + return buildList { while (moveToNext()) add(reader.map()) } +} + +/** [mapRows], dropping the rows [map] returns null for. */ +internal inline fun Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List { + 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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index df22fa9..51b8ea8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.ReminderMethod import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis -import java.time.Instant import java.time.ZoneId -import java.time.ZoneOffset 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 // 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). - val end = if (isNull(EventDetailProjection.IDX_DTEND)) { - begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)) - } else { - getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin) - } + val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin) + ?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))) // Kept raw (no untitled fallback): the detail screen substitutes its own // 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 // from the resolved display colour: the edit form needs to tell the two // cases apart, while the instance carries the calendar fallback for display. - val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { - null - } else { - getInt(EventDetailProjection.IDX_EVENT_COLOR) - } + val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR) val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY) 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 // event's date is its UTC date. val displayReminders = if (isAllDay) { - val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate() + val startDate = utcDate(begin) val zone = ZoneId.systemDefault() reminders.map { it.copy( @@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore( 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( instance = instance, description = getString(EventDetailProjection.IDX_DESCRIPTION), @@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore( attendees = attendees, rrule = getString(EventDetailProjection.IDX_RRULE), reminders = displayReminders, - status = status, + status = eventStatus(EventDetailProjection.IDX_STATUS), // BUSY and DEFAULT are both 0, so a null column folds into the same // default these mappers already return — no isNull guard needed. availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index fe5903f..98dbe3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -609,7 +609,7 @@ internal fun exdateAfter( * optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of * a zoned one ([exdateListZone] reads that half). */ -private fun exdateStamps(exdate: String?): List = exdate +internal fun exdateStamps(exdate: String?): List = exdate ?.withoutExdateParameters() ?.split(',') ?.map { it.trim() } @@ -879,7 +879,7 @@ internal fun buildImportedEventValues( */ private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String { 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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt index d20dad8..1657e5b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt @@ -1,13 +1,9 @@ package de.jeanlucmakiola.calendula.data.calendar 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.deriveIcsUid -import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis -import java.time.Instant import java.time.ZoneId -import java.time.ZoneOffset /** * 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 isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0 - // Recurring rows store DURATION instead of DTEND; reconstruct the end from it - // so the writer can render DTEND. A missing/blank both means a zero-length event. - 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)) - } + // Unclamped: the writer renders DTEND as stored. + val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION) return IcsEvent( uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart), @@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent( location = getString(EventExportProjection.IDX_LOCATION), description = getString(EventExportProjection.IDX_DESCRIPTION), reminderMinutes = if (isAllDay) { - val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate() + val startDate = utcDate(dtStart) reminderMinutes.map { fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) } } else { reminderMinutes }, - status = status, + status = eventStatus(EventExportProjection.IDX_STATUS), availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), calendarName = calendarName, ) @@ -112,11 +97,7 @@ internal fun exportExDates( ): List { if (exdate.isNullOrBlank()) return emptyList() val zone = exdateListZone(exdate, eventTimezone) - return exdate - .withoutExdateParameters() - .split(',') - .map { it.trim() } - .filter { it.isNotEmpty() } + return exdateStamps(exdate) .map { stamp -> if (isAllDay) { stamp.substringBefore('T') diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index a1a9a18..e8b49c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? { // string, so it belongs to the UI layer where it can be localised (#321). val title = getString(InstanceProjection.IDX_TITLE).orEmpty() - val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) { - getInt(InstanceProjection.IDX_CALENDAR_COLOR) - } else { - getInt(InstanceProjection.IDX_EVENT_COLOR) - } + val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR) return EventInstance( instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 47caa39..e9dae51 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -1,7 +1,16 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.content.ContentUris +import android.net.Uri 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 { val COLUMNS: Array = arrayOf( CalendarContract.Calendars._ID, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index 441f8c1..1b7dcf2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis 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 @@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? { // long-running birthdays/anniversaries still surface in search. if (isNull(SearchProjection.IDX_DTSTART)) return null val dtStart = getLong(SearchProjection.IDX_DTSTART) - val end = when { - !isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) - else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) - }.coerceAtLeast(dtStart) + val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION) + .coerceAtLeast(dtStart) val title = getString(SearchProjection.IDX_TITLE).orEmpty() - val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { - getInt(SearchProjection.IDX_CALENDAR_COLOR) - } else { - getInt(SearchProjection.IDX_EVENT_COLOR) - } + val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR) val eventId = getLong(SearchProjection.IDX_ID) return EventInstance( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt index 6b13564..29b140d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt @@ -1,9 +1,9 @@ package de.jeanlucmakiola.calendula.data.reminders import android.content.Context -import android.content.ContentUris import android.provider.CalendarContract import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.data.calendar.instancesUri import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance import javax.inject.Inject import javax.inject.Singleton @@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor( ) : ReminderInstanceSource { override fun occurrences(fromMillis: Long, toMillis: Long): List { - val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { - ContentUris.appendId(this, fromMillis) - ContentUris.appendId(this, toMillis) - }.build() + val uri = instancesUri(fromMillis, toMillis) // `visible` is the flag the app's one visibility model writes (#75). // The status clause mirrors CalendarDataSource.instances: NULL means // "normal", so a bare `!= CANCELED` would drop every ordinary event. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index bb00bbc..bbd9646 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -148,23 +148,21 @@ enum class EventFormProblem { fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { // All-day events are date-anchored and carry a nominal "UTC" that is an // anchor, not a location, so they never pin a zone. - val pinnedZone = if (instance.isAllDay) { + val pinned = if (instance.isAllDay) { null } else { eventTimezone ?.takeIf { it != zone.id } // An unparseable id (a malformed sync row) can't be honoured or // 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 finish = Instant.fromEpochMilliseconds(endMillis) val (start, end) = if (instance.isAllDay) { - val startDate = begin.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))) + allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date) } else { 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(), availability = availability, accessLevel = accessLevel, - rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, + rrule = rrule.normalizedRrule(), // The provider fills EVENT_COLOR from the key, so [color] is the // swatch either way; a null colour means the event inherits its // calendar's colour. @@ -232,6 +230,19 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon 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 { + 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 * 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 { if (isAllDay) return false - val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false - return pinned.id != target.id + return resolvedZone(target).id != target.id } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt index 748a0e4..12944f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt @@ -1,8 +1,8 @@ package de.jeanlucmakiola.calendula.domain import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.number import kotlinx.datetime.plus @@ -101,19 +101,13 @@ object EventSearch { val today = todayStart.toLocalDateTime(zone).date val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) } - val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today } - val order = compareBy { if (upcoming(it)) 0 else 1 } - .thenComparator { a, b -> - // What is left reads forwards; what is behind us reads backwards - // from today, nearest first. - if (upcoming(a)) { - a.event.dayStart(zone).compareTo(b.event.dayStart(zone)) - } else { - b.event.dayStart(zone).compareTo(a.event.dayStart(zone)) - } - } + val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today } + // What is left reads forwards; what is behind us reads backwards from + // today, nearest first. + val ordered = upcoming.sortedBy { it.event.dayStart(zone) } + + past.sortedByDescending { it.event.dayStart(zone) } - val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly } + val (inDescriptions, dated) = ordered.partition { it.descriptionOnly } return SearchResults( months = groupByMonth(dated, zone, todayStart), inDescriptions = inDescriptions.map { it.hit }, @@ -126,33 +120,18 @@ object EventSearch { zone: TimeZone, todayStart: Instant, ): List { - val currentKey = monthKey(todayStart.toLocalDateTime(zone).date) - return dated - .groupBy { monthKey(it.event.spanFirstDay(zone)) } - .entries - .sortedWith( - compareBy>> { - when { - it.key == currentKey -> 0 - it.key > currentKey -> 1 - else -> 2 - } - }.thenComparator { a, b -> - if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key) - }, + val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) } + val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } } + val (behind, ahead) = groups.keys.partition { it < current } + return (ahead.sorted() + behind.sortedDescending()).map { month -> + SearchMonth( + year = month.year, + monthNumber = month.month.number, + hits = groups.getValue(month).map { it.hit }, ) - .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( candidate: SearchCandidate, tokens: List, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index 9d8ea25..b77b8af 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -17,12 +17,15 @@ import kotlin.time.Instant import kotlin.time.toJavaInstant 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 * resolves them in at write time: the form's own pinned zone, else [deviceZone]. */ 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt index a537275..0a6c0c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -1,8 +1,5 @@ package de.jeanlucmakiola.calendula.domain -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.hours @@ -35,17 +32,11 @@ fun buildInsertEventForm( now: Instant, ): EventForm { 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 ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } ?: now.toLocalDateTime(zone).date - val endDate = endMillis - ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } - ?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) } - ?: startDate - FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0))) + val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } + allDayFormTimes(startDate, endExclusive) } else { val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now) val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt } @@ -62,7 +53,7 @@ fun buildInsertEventForm( description = description.orEmpty(), // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" // some callers include. - rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, + rrule = rrule.normalizedRrule(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt index 7b6ecde..3e55232 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt @@ -1,5 +1,8 @@ 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.LocalDate import kotlinx.datetime.LocalDateTime @@ -57,24 +60,13 @@ fun parseSimpleRecurrence( rrule: String, zone: TimeZone = TimeZone.currentSystemDefault(), ): SimpleRecurrence? { - val parts = rrule.removePrefix("RRULE:").split(';') - .filter { it.isNotBlank() } - .associate { token -> - val eq = token.indexOf('=') - if (eq <= 0) return null - token.substring(0, eq).uppercase() to token.substring(eq + 1) - } + val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value } if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) { return null } - val freq = when (parts["FREQ"]?.uppercase()) { - "DAILY" -> RecurrenceFreq.Daily - "WEEKLY" -> RecurrenceFreq.Weekly - "MONTHLY" -> RecurrenceFreq.Monthly - "YEARLY" -> RecurrenceFreq.Yearly - else -> return null - } + val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) } + ?: return null 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 @@ -124,20 +116,27 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): when (val e = end) { RecurrenceEnd.Never -> Unit is RecurrenceEnd.Until -> { - val utc = LocalDateTime(e.date, LocalTime(23, 59, 59)) - .toInstant(zone) - .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, - ), - ) + append(";UNTIL=") + append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic()) } 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 = + rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart) + internal val RRULE_DAY_CODES: Map = mapOf( DayOfWeek.MONDAY to "MO", DayOfWeek.TUESDAY to "TU", @@ -165,11 +164,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String { val key = part.substringBefore('=').trim().uppercase() key == "UNTIL" || key == "COUNT" } - val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC) - val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format( - until.year, until.month.number, until.day, - until.hour, until.minute, until.second, - ) + val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic() 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 * a [toRRule]-rendered value round-trips to the day the user picked. */ -private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching { - val date = LocalDate( - raw.substring(0, 4).toInt(), - raw.substring(4, 6).toInt(), - 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() +private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? { + val date = parseBasicDate(raw) ?: return null + if (raw.length < 15 || raw[8] != 'T') return date + return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt index b55db7d..205e2a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt @@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { if (oldStart == newStart) return rrule 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 + var freq = false var weekly = false + var byDay = false val rebuilt = parts.map { part -> - val eq = part.indexOf('=') - if (eq <= 0) return null - val key = part.substring(0, eq).uppercase() - val value = part.substring(eq + 1).trim() - when (key) { + val value = part.value.trim() + when (part.key) { "FREQ" -> { + freq = true weekly = value.equals("WEEKLY", ignoreCase = true) - part + part.raw } "BYDAY" -> { + byDay = true val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null if (!value.equals(old, ignoreCase = true)) return null "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" } - "INTERVAL", "COUNT", "UNTIL", "WKST" -> part + "INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw else -> return null } } // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence. - if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null - if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null + if (!freq || (byDay && !weekly)) return null return prefix + rebuilt.joinToString(";") } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt new file mode 100644 index 0000000..324a153 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index 0c95821..75d4020 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -2,19 +2,17 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus -import kotlinx.datetime.LocalDate +import de.jeanlucmakiola.calendula.domain.zoneOrNull import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.number import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.math.ceil +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds 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 /** @@ -318,13 +316,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault dtEnd: IcsDateTime?, duration: String?, ): Instant { - val explicit = dtEnd?.instant ?: duration?.let { - Instant.fromEpochMilliseconds( - start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it), - ) - } + val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds } 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, start: IcsDateTime): List { 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 return lines .flatMap { line -> line.value.split(',').map { line to it.trim() } } .mapNotNull { (line, token) -> when { token.isEmpty() -> null - start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode) - token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp) + start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic() + token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic() else -> parseBasicDate(token) - ?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) } + ?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() } } } .distinct() @@ -373,7 +367,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null val zone = when { token.endsWith("Z") -> TimeZone.UTC - else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() } + else -> line.params["TZID"]?.let(::zoneOrNull) ?: startZone } 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") 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) val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone) 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) = 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 * 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) } } - /** Index of the matching `END:` at/after [from], or list end. */ /** * 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 * nested `VALARM` is part of the body and is not a boundary. */ - fun indexOfNextComponent(lines: List, from: Int, end: Int): Int { - var i = from - while (i < end) { + fun indexOfNextComponent(lines: List, from: Int, end: Int): Int = + (from until end).firstOrNull { i -> val line = parseContentLine(lines[i]) - if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) { - return i - } - i++ - } - return end - } + line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO")) + } ?: end - fun indexOfEnd(lines: List, from: Int, component: String): Int { - var i = from - while (i < lines.size) { + /** Index of the matching `END:` at/after [from], or list end. */ + fun indexOfEnd(lines: List, from: Int, component: String): Int = + (from until lines.size).firstOrNull { i -> val line = parseContentLine(lines[i]) - if (line != null && line.name == "END" && - line.value.trim().equals(component, true) - ) { - return i - } - i++ - } - return lines.size - } + line != null && line.name == "END" && line.value.trim().equals(component, true) + } ?: lines.size - fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) { - "TENTATIVE" -> EventStatus.Tentative - "CANCELLED" -> EventStatus.Cancelled - 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() + fun mapIcsStatus(value: String): EventStatus = + EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) } + ?: EventStatus.Confirmed } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt index 7f67573..500f1f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.domain.ics +import de.jeanlucmakiola.calendula.domain.rruleParts + private val VALID_FREQ = setOf( "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", ) @@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule { return kept.takeIf { it.isNotEmpty() }?.joinToString(",") } - for (part in rule.split(';')) { - // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. - // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part - // being dropped, so it falls through and marks the rule repaired. - if (part.isBlank()) continue - val key = part.substringBefore('=', "").trim().uppercase() - val value = if ('=' in part) part.substringAfter('=').trim() else "" + // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. + // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part + // being dropped, so it falls through and marks the rule repaired. + for (part in rruleParts(raw.orEmpty().trim())) { + val key = part.key?.trim().orEmpty() + val value = part.value.trim() val kept = when { key.isEmpty() || value.isEmpty() -> null key == "FREQ" -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt index c26dd9c..2d5ee6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -2,9 +2,8 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus -import kotlinx.datetime.LocalDateTime +import de.jeanlucmakiola.calendula.domain.zoneOrNull import kotlinx.datetime.TimeZone -import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -41,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { private fun MutableList.appendEvent(event: IcsEvent, dtStamp: Instant) { add("BEGIN:VEVENT") add("UID:${event.uid}") - add("DTSTAMP:${utcStamp(dtStamp)}") + add("DTSTAMP:${dtStamp.toUtcBasic()}") add("SUMMARY:${escapeText(event.summary)}") appendTimes(event) event.recurrenceRule?.takeIf { it.isNotBlank() } @@ -63,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { private fun MutableList.appendTimes(event: IcsEvent) = when { event.isAllDay -> { - add("DTSTART;VALUE=DATE:${utcDate(event.start)}") - add("DTEND;VALUE=DATE:${utcDate(event.end)}") + add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}") + add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}") } // Recurring: anchor to wall-clock in the event's own zone. event.recurrenceRule?.isNotBlank() == true -> { - val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull() + val zone = zoneOrNull(event.zoneId) if (zone != null) { - add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}") - add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}") + add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}") + add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}") } else { // Unknown zone id → fall back to plain UTC instants. - add("DTSTART:${utcStamp(event.start)}") - add("DTEND:${utcStamp(event.end)}") + add("DTSTART:${event.start.toUtcBasic()}") + add("DTEND:${event.end.toUtcBasic()}") } } else -> { - add("DTSTART:${utcStamp(event.start)}") - add("DTEND:${utcStamp(event.end)}") + add("DTSTART:${event.start.toUtcBasic()}") + 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". fun triggerValue(minutes: Int): String = 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, - ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index e8ab23b..1467e04 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -1,11 +1,9 @@ package de.jeanlucmakiola.calendula.domain.ics 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 kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -23,10 +21,7 @@ import kotlinx.datetime.toLocalDateTime */ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { val (start, end) = if (isAllDay) { - val startDate = this.start.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))) + allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date) } else { this.start.toFormTime(zone) to this.end.toFormTime(zone) } @@ -40,6 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { description = description.orEmpty(), reminders = semanticReminderMinutes(), availability = availability, - rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, + rrule = recurrenceRule.normalizedRrule(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt index a8e5f39..645a93e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda import de.jeanlucmakiola.floret.locale.localizedDateFormatter import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate -import java.time.YearMonth 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. @@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t DAYS_PER_WEEK - sinceWeekStart } AgendaRange.ThisMonth -> { - val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth() + val daysInMonth = anchor.yearMonth.numberOfDays daysInMonth - anchor.day + 1 } is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) @@ -105,8 +106,8 @@ fun agendaRangeWindowSummary( locale: Locale, monthAsSpan: Boolean = false, ): String { - val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) - val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) + val javaStart = start.toJavaLocalDate() + val javaEnd = end.toJavaLocalDate() val dayMonth = localizedDateFormatter(locale, "dMMM") val dayMonthYear = localizedDateFormatter(locale, "dMMMy") return when { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index b7d29ff..0859beb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.ui.agenda +import androidx.annotation.StringRes +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.padding 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.material.icons.Icons 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.isDeclined 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.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.eventAccent 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.Position +import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme +import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.localizedDateFormatter import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Instant +import kotlinx.datetime.toJavaLocalDate import java.util.Locale // 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) is AgendaTimeLabel.Starts -> stringResource( R.string.agenda_span_starts, - formatTime(label.start, zone, is24Hour, locale), + formatTimeOfDay(label.start, zone, is24Hour, locale), ) is AgendaTimeLabel.Ends -> stringResource( 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)} – " + - formatTime(label.end, zone, is24Hour, locale) + is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " + + formatTimeOfDay(label.end, zone, is24Hour, locale) } val location = event.location?.takeIf { it.isNotBlank() } 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 { 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" // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. 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, + 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)) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index adbcddc..1972980 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -10,9 +10,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow 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.Box 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons 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.QuickSwitchConfig 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.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.floret.locale.currentLocale import kotlinx.coroutines.launch @@ -379,7 +373,6 @@ internal fun AgendaContent( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun AgendaList( days: List, @@ -444,39 +437,17 @@ private fun AgendaList( // Bottom inset clears the FAB stack so the last row stays tappable. contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), ) { - days.forEach { day -> - stickyHeader(key = "header-${day.date}") { - AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) - } - if (day.events.isEmpty()) { - // An anchored, event-less today (#35) — "nothing left today". - item(key = "placeholder-${day.date}") { - AgendaEmptyDayRow( - text = stringResource(R.string.agenda_no_more_today), - onClick = { onOpenDay(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 = 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)) } - } + agendaDays( + days = days, + today = today, + zone = zone, + // An anchored, event-less today (#35) — "nothing left today". + emptyText = R.string.agenda_no_more_today, + onEmptyClick = onOpenDay, + dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) }, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 4915f16..aee15c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.dayRange import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,11 +32,8 @@ 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.plus -import kotlinx.datetime.toInstant import kotlin.time.Instant import javax.inject.Inject @@ -243,8 +241,5 @@ class AgendaViewModel @Inject constructor( internal const val PAST_CHUNK_DAYS = 30 /** Inclusive instant range from the start of [anchor] through [days] days ahead. */ -internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange { - val from = anchor.atStartOfDayIn(zone) - val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone) - return from..to -} +internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange = + dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 0278279..d319ff0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -53,6 +53,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar 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.DialogAmountField import de.jeanlucmakiola.floret.components.DialogUnitDropdown @@ -317,7 +318,7 @@ private fun ExportCalendarPicker( calendars.forEachIndexed { index, calendar -> val isSelected = calendar.id in selected GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + title = calendar.displayName.orUnnamed(), summary = calendar.description, position = positionOf(index, calendars.size), leading = { CalendarColorChip(calendar.color) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index e070249..5be7aac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.ui.common.AccountKey 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.groupByAccount 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.SourceLogo 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.GroupedHint import de.jeanlucmakiola.floret.components.GroupedSectionHeader @@ -238,21 +239,10 @@ private fun CalendarsList( GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall) } else { local.forEachIndexed { index, calendar -> - val disabled = !calendar.isVisibleInSystem - GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, - summary = calendarRowSummary(calendar), - 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) }, - ) - }, + CalendarManagerRow( + calendar = calendar, + last = index == local.lastIndex, + onSetVisible = onSetVisible, onClick = { onEdit(calendar) }, ) } @@ -323,27 +313,10 @@ private fun CalendarsList( // bottom, dimmed and switchless. val ordered = cals.orderedForManager() ordered.forEachIndexed { index, calendar -> - val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced - GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, - summary = calendarRowSummary(calendar), - 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 - }, + CalendarManagerRow( + calendar = calendar, + last = index == ordered.lastIndex, + onSetVisible = onSetVisible, ) } } @@ -682,17 +655,37 @@ private fun calendarRowSummary(calendar: CalendarSource): String? { * `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and * 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 -private fun EnableSwitch( - calendarName: String, - enabled: Boolean, - onToggle: (Boolean) -> Unit, +private fun CalendarManagerRow( + calendar: CalendarSource, + last: Boolean, + onSetVisible: (Long, Boolean) -> Unit, + onClick: (() -> Unit)? = null, ) { - val label = stringResource(R.string.calendars_visibility_a11y, calendarName) - Switch( - checked = enabled, - onCheckedChange = onToggle, - modifier = Modifier.semantics { contentDescription = label }, + val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced + GroupedRow( + title = calendar.displayName.orUnnamed(), + summary = calendarRowSummary(calendar), + 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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt index 7ec3dfd..c92c1ce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt @@ -57,7 +57,7 @@ fun List.groupByAccount(): List { */ @Composable fun accountGroupTitle(group: CalendarAccountGroup): String { - val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) } + val label = group.label.orUnnamed() return if (!group.ambiguous) { label } else { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index d9a6ce9..af5c46d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -114,7 +114,7 @@ private fun CalendarPickerGroup( ) calendars.forEachIndexed { index, calendar -> GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + title = calendar.displayName.orUnnamed(), position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, checked = calendar.id == selectedId, leading = { CalendarColorChip(calendar.color) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt index cec1580..bbf01f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt @@ -3,10 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.Dp +import de.jeanlucmakiola.calendula.R /** * 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() } } } + +/** A calendar or account name, or the "unnamed" label when it is blank. */ +@Composable +fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarVisibilitySwitch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarVisibilitySwitch.kt new file mode 100644 index 0000000..ca21360 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarVisibilitySwitch.kt @@ -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 }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DayRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DayRange.kt new file mode 100644 index 0000000..27ea2d0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DayRange.kt @@ -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 = + 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt index 252bc9e..7bc93dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt @@ -10,6 +10,8 @@ import androidx.compose.ui.text.font.FontStyle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.SimpleRecurrence 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.floret.locale.localizedDateFormatter import kotlinx.datetime.toJavaLocalDate @@ -32,10 +34,7 @@ import java.util.Locale */ @Composable fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { - val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token -> - val eq = token.indexOf('=') - if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1) - }.toMap() + val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap() val freq = parts["FREQ"]?.uppercase() val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 @@ -171,12 +170,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): .atOffset(ZoneOffset.UTC) .atZoneSameInstant(zone) .toLocalDate() - }.recoverCatching { - val digits = value.takeWhile { it.isDigit() } - LocalDate.of( - digits.substring(0, 4).toInt(), - digits.substring(4, 6).toInt(), - digits.substring(6, 8).toInt(), - ) - }.getOrNull() + }.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt index 4e6bc67..c5b00dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt @@ -9,6 +9,7 @@ import kotlin.time.Instant import kotlin.time.toJavaInstant import java.time.format.DateTimeFormatter import java.util.Locale +import kotlinx.datetime.toLocalDateTime /** * 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)) .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 * 1440 renders as "24:00" in 24h (its established reading) and as midnight diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 1e26b19..6b8ca58 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -89,6 +89,8 @@ import de.jeanlucmakiola.calendula.domain.RichText import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf 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.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.predictiveBack @@ -420,7 +422,7 @@ private fun EventDetailContent( // — 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 // 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 { (primary, secondary) -> secondary ?: primary } Spacer(Modifier.height(2.dp)) @@ -445,7 +447,7 @@ private fun EventDetailContent( iconContentDescription = stringResource(R.string.event_detail_calendar), ) { Text( - text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) } + text = state.calendarName?.orUnnamed() ?: stringResource(R.string.event_detail_calendar_unknown), style = MaterialTheme.typography.titleMedium, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index f1b7a1e..356981f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -33,6 +33,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes 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.identity.LocalFloretDarkTheme import kotlinx.coroutines.launch @@ -85,11 +88,9 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -832,7 +833,7 @@ private fun EventEditContent( text = stringResource( R.string.event_edit_managed_hint, selectedCalendar?.displayName - ?.ifBlank { stringResource(R.string.calendar_unnamed) } + ?.orUnnamed() .orEmpty(), ), style = MaterialTheme.typography.bodySmall, @@ -939,7 +940,7 @@ private fun EventEditContent( ) { Text( text = selectedCalendar?.displayName - ?.ifBlank { stringResource(R.string.calendar_unnamed) } + ?.orUnnamed() ?: stringResource(R.string.event_edit_error_no_calendar), style = MaterialTheme.typography.titleMedium, color = if (selectedCalendar == null) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt index 9b37431..68191a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt @@ -20,8 +20,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R 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.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.GroupedRow import de.jeanlucmakiola.floret.components.positionOf @@ -43,6 +45,7 @@ fun CalendarFilterList( is FilterUiState.Failure -> FilterMessage(s.reason, modifier) is FilterUiState.Success -> FilterList( groups = s.groups, + hidden = s.hidden, onSetVisible = viewModel::setVisible, modifier = modifier, ) @@ -56,23 +59,15 @@ fun CalendarFilterList( */ @Composable private fun FilterList( - groups: List, + groups: List, + hidden: Set, onSetVisible: (Long, Boolean) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier.fillMaxWidth()) { groups.forEach { group -> - val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) } Text( - text = if (group.ambiguous) { - stringResource( - R.string.calendars_account_from_source, - account, - sourceAppName(group.accountType), - ) - } else { - account - }, + text = accountGroupTitle(group), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding( @@ -83,18 +78,19 @@ private fun FilterList( ), ) group.calendars.forEachIndexed { index, cal -> + val visible = cal.id !in hidden GroupedRow( - title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + title = cal.displayName.orUnnamed(), position = positionOf(index, group.calendars.size), minHeight = 56.dp, leading = { CalendarColorChip(cal.color) }, trailing = { Checkbox( - checked = cal.visible, + checked = visible, onCheckedChange = { onSetVisible(cal.id, it) }, ) }, - onClick = { onSetVisible(cal.id, !cal.visible) }, + onClick = { onSetVisible(cal.id, !visible) }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt index ac693f5..765d9ed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.filter 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 @@ -10,26 +11,9 @@ import de.jeanlucmakiola.calendula.domain.FailureReason sealed interface FilterUiState { data object Loading : FilterUiState data class Failure(val reason: FailureReason) : FilterUiState - data class Success(val groups: List) : FilterUiState + /** A calendar shows when its id is *not* in [hidden]. */ + data class Success( + val groups: List, + val hidden: Set, + ) : 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, -) - -data class CalendarRow( - val id: Long, - val displayName: String, - val color: Int, - val visible: Boolean, -) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt index 5d615c4..fda4c1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs -import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.ui.common.groupByAccount @@ -39,7 +38,7 @@ class FilterViewModel @Inject constructor( if (failure != null) { FilterUiState.Failure(failure) } else { - FilterUiState.Success(groupCalendarsForFilter(enabled, hidden)) + FilterUiState.Success(enabled.groupByAccount(), hidden) } } .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, - hidden: Set, -): List = - 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, - ) - }, - ) - } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt index 78d05dd..8154c25 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt @@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import java.util.Locale /** @@ -23,8 +22,7 @@ internal fun monthChipTime( locale: Locale, ): String? { if (event.isAllDay || continuesLeft) return null - val start = event.start.toLocalDateTime(zone).time - return formatTimeOfDay(start.hour, start.minute, is24Hour, locale) + return formatTimeOfDay(event.start, zone, is24Hour, locale) } /** The narrowest chip that may show a time — the text it needs, plus its chrome. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 8e49ce8..ca58c5f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.setValue 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.ScreenActions 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.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -191,10 +192,13 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus import kotlinx.datetime.YearMonth +import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate import kotlin.time.Instant import java.time.format.TextStyle as JavaTextStyle import java.util.Locale +import kotlinx.datetime.toJavaMonth +import kotlinx.datetime.yearMonth private val LocalTodayPulse = staticCompositionLocalOf { 0 } @@ -343,7 +347,7 @@ fun MonthScreen( if (dense) { val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) .plus(3, DateTimeUnit.DAY) - YearMonth(midweek.year, midweek.month) + midweek.yearMonth } else { 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) { mutableStateOf(viewStyle == MonthViewStyle.Paged) } @@ -421,7 +425,7 @@ fun MonthScreen( val jumpToToday: () -> Unit = { todayPulse++ if (scrolling) { - val targetMonth = YearMonth(today.year, today.month) + val targetMonth = today.yearMonth pendingScrollMonth = targetMonth val requestId = ++scrollRequestId viewModel.goToToday() @@ -439,13 +443,13 @@ fun MonthScreen( } } else { viewModel.goToToday() - jumpToMonth(YearMonth(today.year, today.month)) + jumpToMonth(today.yearMonth) } } // Drawer jump-to-date. val jumpToDate: (LocalDate) -> Unit = { target -> if (scrolling) { - val targetMonth = YearMonth(target.year, target.month) + val targetMonth = target.yearMonth pendingScrollMonth = targetMonth val requestId = ++scrollRequestId viewModel.goToDate(target) @@ -463,7 +467,7 @@ fun MonthScreen( } } else { 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. onSelectDay = { date -> viewModel.selectDate(date) - jumpToMonth(YearMonth(date.year, date.month)) + jumpToMonth(date.yearMonth) }, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -720,7 +724,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter())) days.forEach { dow -> val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY - val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) + val javaDow = dow.toJavaDayOfWeek() Text( text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), style = MaterialTheme.typography.labelMedium, @@ -939,7 +943,7 @@ internal fun ContinuousMonthGrid( modifier: Modifier = Modifier, ) { 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() LazyColumn( state = listState, @@ -1837,7 +1841,6 @@ private fun SplitDots( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun SplitMonthAgendaPane( state: MonthUiState.Success, @@ -1855,33 +1858,16 @@ private fun SplitMonthAgendaPane( val dimCutoff = LocalDimCutoff.current val listState = key(state.month) { rememberLazyListState() } LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { - days.forEach { day -> - val events = state.instancesByDay[day].orEmpty() - stickyHeader(key = "header-$day") { - AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay) - } - if (events.isEmpty()) { - item(key = "empty-$day") { - AgendaEmptyDayRow( - text = stringResource(R.string.month_split_no_events), - 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)) } - } + agendaDays( + days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) }, + today = state.today, + zone = state.zone, + emptyText = R.string.month_split_no_events, + onEmptyClick = onCreateEvent, + dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) }, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) } } @@ -2715,7 +2701,7 @@ private fun DayNumberCell( private fun shortMonthName(date: LocalDate): String { val locale = currentLocale() return remember(date.month, locale) { - java.time.Month.of(date.month.ordinal + 1) + date.month.toJavaMonth() .getDisplayName(JavaTextStyle.SHORT, locale) } } @@ -2878,7 +2864,7 @@ private fun formatMonthTitle( abbreviated: Boolean = false, ): String = formatCalendarTitle( - date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1), + date = ym.firstDay.toJavaLocalDate(), locale = locale, currentYear = currentYear, skeleton = if (abbreviated) "LLL" else "LLLL", diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index 67f13db..f27dcc6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -14,10 +14,10 @@ import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone -import kotlinx.datetime.YearMonth import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock +import kotlinx.datetime.yearMonth /** * A live, scaled-down Month view in a given [MonthViewStyle], for the settings @@ -51,7 +51,7 @@ internal fun MonthStylePreview( state = sample.continuous, listState = rememberLazyListState( initialFirstVisibleItemIndex = itemIndexForMonth( - monthIndexOf(YearMonth(today.year, today.month)), + monthIndexOf(today.yearMonth), ), ), showWeekNumbers = false, @@ -109,7 +109,7 @@ private fun sampleMonthState( weekStart: DayOfWeek, zone: TimeZone, ): SampleMonth { - val ym = YearMonth(today.year, today.month) + val ym = today.yearMonth val first = LocalDate(ym.year, ym.month, 1) val events = sampleMonthEvents(first, today, zone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index f59c4e3..4ea3034 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -1,5 +1,7 @@ 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.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -39,15 +41,13 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.atTime import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus -import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant import javax.inject.Inject +import kotlinx.datetime.yearMonth /** How many months either side of the requested one [MonthViewModel.month] keeps cached. */ private const val MONTH_CACHE_SPAN = 2 @@ -101,7 +101,7 @@ class MonthViewModel @Inject constructor( } .stateWhileSubscribed(viewModelScope, todayDate) - private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) + private val _month = MutableStateFlow(todayDate.yearMonth) val month: StateFlow = _month /** @@ -182,11 +182,11 @@ class MonthViewModel @Inject constructor( /** Last reported visible range, so a widening step can re-centre on it. */ @Volatile - private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + private var visibleMonths = monthIndexOf(todayDate.yearMonth) .let { it..it } private val _loadedMonths = MutableStateFlow( - monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + monthIndexOf(todayDate.yearMonth) .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 // 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. - val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws) - val last = firstOfMonth(yearMonthForIndex(window.last)) - .plus(1, DateTimeUnit.MONTH) - .minus(1, DateTimeUnit.DAY) - .startOfGridWeek(ws) + val first = yearMonthForIndex(window.first).firstDay.startOfWeek(ws) + val last = yearMonthForIndex(window.last).lastDay + .startOfWeek(ws) .plus(6, DateTimeUnit.DAY) - val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) + val range = dayRange(first, last, zone) combine( repository.calendars(), repository.instances(range), @@ -306,7 +304,7 @@ class MonthViewModel @Inject constructor( * silently list a date the grid isn't pointing at. */ 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 if (target != _month.value) _month.value = target } @@ -321,12 +319,12 @@ class MonthViewModel @Inject constructor( } else { val next = selected.plus(days, DateTimeUnit.DAY) _selectedDate.value = next - _month.value = YearMonth(next.year, next.month) + _month.value = next.yearMonth } } fun goToToday() { - _month.value = YearMonth(todayDate.year, todayDate.month) + _month.value = todayDate.yearMonth _selectedDate.value = null } @@ -337,8 +335,8 @@ class MonthViewModel @Inject constructor( */ private fun followToday(old: LocalDate, new: LocalDate) { if (viewStyle.value.isScrolling) return - if (_month.value != YearMonth(old.year, old.month)) return - _month.value = YearMonth(new.year, new.month) + if (_month.value != old.yearMonth) return + _month.value = new.yearMonth _selectedDate.value = null } @@ -358,7 +356,7 @@ class MonthViewModel @Inject constructor( /** Jump to the month containing [date] (drawer jump-to-date). */ fun goToDate(date: LocalDate) { - _month.value = YearMonth(date.year, date.month) + _month.value = date.yearMonth _selectedDate.value = date } @@ -408,7 +406,7 @@ internal fun layoutMonthWeeks( byDay: DayIndex, zone: TimeZone, ): List { - val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) + val gridStart = ym.firstDay.startOfWeek(weekStart) val weekCount = weekRowsInMonth(ym, weekStart) return (0 until weekCount).map { row -> @@ -529,12 +527,8 @@ internal fun monthGridRange( weekStart: DayOfWeek, zone: TimeZone, ): ClosedRange { - val firstOfMonth = LocalDate(ym.year, ym.month, 1) - val gridStart = firstOfMonth.startOfGridWeek(weekStart) - val gridEnd = gridStart.plus(41, DateTimeUnit.DAY) - val start = gridStart.atStartOfDayIn(zone) - val end = gridEnd.atTime(23, 59, 59).toInstant(zone) - return start..end + val gridStart = ym.firstDay.startOfWeek(weekStart) + return dayRange(gridStart, gridStart.plus(41, DateTimeUnit.DAY), zone) } /** @@ -562,7 +556,7 @@ internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLea * the first of the visible month. */ internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate = - if (YearMonth(today.year, today.month) == month) { + if (today.yearMonth == month) { today } else { 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) } -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 * 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) internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { - val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) - return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 + val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart) + return base.daysUntil(date.startOfWeek(weekStart)) / 7 } 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). */ 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 // most of it, the same rule the title uses. 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. */ internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange { - val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1 - val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last)) - .plus(1, DateTimeUnit.MONTH) - .minus(1, DateTimeUnit.DAY) + val first = weekIndexOf(yearMonthForIndex(window.first).firstDay, weekStart) - 1 + val lastMonthEnd = yearMonthForIndex(window.last).lastDay return first..(weekIndexOf(lastMonthEnd, weekStart) + 1) } /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ 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 daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() - return (leadOffset + daysInMonth + 6) / 7 + return (leadOffset + ym.numberOfDays + 6) / 7 } /** @@ -727,9 +716,3 @@ internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek { 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) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt index 86b5775..ea498de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt @@ -8,20 +8,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch 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.GroupedRow import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton @@ -122,7 +121,7 @@ internal fun HiddenCalendarsStep( calendars.forEachIndexed { index, calendar -> val off = !calendar.isVisibleInSystem GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + title = calendar.displayName.orUnnamed(), summary = calendar.accountName.takeIf { it.isNotBlank() }, position = when { calendars.size == 1 -> Position.Alone @@ -133,8 +132,8 @@ internal fun HiddenCalendarsStep( dimmed = off, trailing = if (calendar.hasVisibilitySwitch) { { - CalendarSwitch( - calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + CalendarVisibilitySwitch( + calendarName = calendar.displayName.orUnnamed(), enabled = !off, onToggle = { on -> onSetVisible(calendar.id, on) }, ) @@ -149,18 +148,3 @@ internal fun HiddenCalendarsStep( 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 }, - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index 4d44c31..590a008 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -25,6 +25,7 @@ import de.jeanlucmakiola.calendula.ui.common.DurationPicker import de.jeanlucmakiola.calendula.ui.common.durationLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon 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.ExpandIcon import de.jeanlucmakiola.floret.components.GroupedHint @@ -129,7 +130,7 @@ internal fun EventFormScreen( durationCalendars.forEachIndexed { index, calendar -> val override = state.perCalendarEventDuration[calendar.id] GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + title = calendar.displayName.orUnnamed(), summary = override?.let { durationLabel(it) } ?: stringResource( R.string.settings_calendar_duration_inherits, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index bea57ba..b6fd7b2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -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.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.durationLabel +import de.jeanlucmakiola.calendula.ui.common.orUnnamed import de.jeanlucmakiola.floret.android.hasPermission import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.ExpandIcon @@ -172,7 +173,7 @@ internal fun NotificationsScreen( // own section — link there instead. if (calendar.id in state.managedCalendarIds) { GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + title = calendar.displayName.orUnnamed(), summary = stringResource(R.string.settings_calendar_reminders_managed_hint), position = Position.Alone, leading = { CalendarColorChip(calendar.color) }, @@ -189,7 +190,7 @@ internal fun NotificationsScreen( } val expanded = calendar.id in expandedCalendars GroupedRow( - title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + title = calendar.displayName.orUnnamed(), position = if (expanded) Position.Top else Position.Alone, leading = { CalendarColorChip(calendar.color) }, trailing = { ExpandIcon(expanded) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 2dac5c6..b214efc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.ReorderableRowHeight import de.jeanlucmakiola.floret.locale.currentLocale import kotlinx.datetime.DayOfWeek import java.time.format.TextStyle as JavaTextStyle +import kotlinx.datetime.toJavaDayOfWeek /** * Views: everything that changes how a calendar view reads, grouped by the view @@ -504,7 +505,7 @@ private val WEEK_START_OPTIONS: List = internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) { WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto) // 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()) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index e19ed1e..68c08ab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.startOfWeek import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 66fe65d..2097fbd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -202,10 +202,12 @@ import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime import java.time.format.TextStyle as JavaTextStyle import java.util.Locale +import kotlinx.datetime.yearMonth /** One lane of the all-day strip, sized to a trimmed bar line (#190). */ private val ALL_DAY_ROW_HEIGHT = 20.dp @@ -1084,7 +1086,7 @@ private fun WeekDayHeader( .fillMaxWidth() .padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET), ) { date -> - val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) + val javaDow = date.dayOfWeek.toJavaDayOfWeek() val isToday = date == today Column( modifier = Modifier @@ -1958,7 +1960,7 @@ private fun formatWeekTitle( abbreviated: Boolean = false, ): String { return formatCalendarTitle( - date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), + date = weekStart.yearMonth.firstDay.toJavaLocalDate(), locale = locale, currentYear = currentYear, skeleton = if (abbreviated) "LLL" else "LLLL", @@ -1974,7 +1976,7 @@ private fun formatDayTitle( abbreviated: Boolean = false, ): String = formatCalendarTitle( - date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), + date = date.toJavaLocalDate(), locale = locale, currentYear = currentYear, skeleton = if (abbreviated) "dMMM" else "EEEdMMM", diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 5fcbe92..3655532 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.dayRange import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -41,11 +42,9 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toKotlinUtcOffset import kotlinx.datetime.toJavaZoneId import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.atTime import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus -import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import java.util.Locale import kotlin.time.Instant @@ -342,19 +341,9 @@ internal fun layoutAllDay( private infix fun IntRange.overlaps(other: IntRange): Boolean = 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]. */ -internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange { - val from = start.atStartOfDayIn(zone) - val to = start.plus(count - 1, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone) - return from..to -} +internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange = + dayRange(start, start.plus(count - 1, DateTimeUnit.DAY), zone) /** * This instant's wall-clock time as minutes past [day]'s midnight in [zone] — diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index 4936177..d9790ce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.startOfWeek import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index b04a467..fcf402c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -794,11 +794,11 @@ class CalendarRepositoryImplTest { } @Test - fun `eventColorPalette delegates to the data source for the given calendar`( + fun `eventColorPalette reads the given calendar's published palette`( @TempDir tempDir: Path, ) = runTest { val fake = FakeCalendarDataSource().apply { - eventColorPaletteResult = { id -> + publishedEventColorsResult = { id -> if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList() } } @@ -929,7 +929,6 @@ class CalendarRepositoryImplTest { val published = listOf(EventColorOption("1", 0xFF808080.toInt())) val fake = FakeCalendarDataSource().apply { publishedEventColorsResult = { published } - eventColorPaletteResult = { emptyList() } } val repo = testRepository(fake, tempDir, Dispatchers.Unconfined) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index e4adc62..e22ec49 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -22,7 +22,6 @@ internal class FakeCalendarDataSource : CalendarDataSource { var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } - var eventColorPaletteResult: (Long) -> List = { emptyList() } var publishedEventColorsResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() /** The [calendarIds] the last [exportableEvents] call received (null = all). */ @@ -80,8 +79,6 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun searchEvents(query: String): List = searchResult(query) override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? = eventDetailResult(eventId) - override fun eventColorPalette(calendarId: Long): List = - eventColorPaletteResult(calendarId) override fun publishedEventColors(calendarId: Long): List = publishedEventColorsResult(calendarId) override fun exportableEvents( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/AccountGroupsTest.kt similarity index 59% rename from app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt rename to app/src/test/java/de/jeanlucmakiola/calendula/ui/common/AccountGroupsTest.kt index 7900f72..60a4500 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/AccountGroupsTest.kt @@ -1,10 +1,10 @@ -package de.jeanlucmakiola.calendula.ui.filter +package de.jeanlucmakiola.calendula.ui.common import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.CalendarSource import org.junit.jupiter.api.Test -class FilterGroupingTest { +class AccountGroupsTest { private fun cal( id: Long, @@ -28,35 +28,18 @@ class FilterGroupingTest { 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 }) .containsExactly("Personal", "Work").inOrder() 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 fun `blank account name falls back to type`() { - val groups = groupCalendarsForFilter( - listOf(cal(1, "Birthdays", account = "", type = "LOCAL")), - hidden = emptySet(), - ) - assertThat(groups.single().account).isEqualTo("LOCAL") + val groups = listOf(cal(1, "Birthdays", account = "", type = "LOCAL")).groupByAccount() + assertThat(groups.single().label).isEqualTo("LOCAL") } /** #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"), ) - val groups = groupCalendarsForFilter(calendars, hidden = emptySet()) + val groups = calendars.groupByAccount() assertThat(groups).hasSize(2) assertThat(groups.map { it.accountType }) @@ -78,10 +61,7 @@ class FilterGroupingTest { @Test fun `one name under one type is not ambiguous`() { - val groups = groupCalendarsForFilter( - listOf(cal(1, "Personal", "me@example.com")), - hidden = emptySet(), - ) + val groups = listOf(cal(1, "Personal", "me@example.com")).groupByAccount() assertThat(groups.single().ambiguous).isFalse() } @@ -89,13 +69,10 @@ class FilterGroupingTest { /** Two different names from the same app are still two plain groups. */ @Test fun `different names under one type are not ambiguous`() { - val groups = groupCalendarsForFilter( - listOf( - cal(1, "Personal", "alice@example.com"), - cal(2, "Shared", "bob@example.com"), - ), - hidden = emptySet(), - ) + val groups = listOf( + cal(1, "Personal", "alice@example.com"), + cal(2, "Shared", "bob@example.com"), + ).groupByAccount() assertThat(groups).hasSize(2) assertThat(groups.none { it.ambiguous }).isTrue() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index 71e9df2..c05cd3a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.month +import de.jeanlucmakiola.calendula.ui.common.startOfWeek import com.google.common.truth.Truth.assertThat import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -120,8 +121,8 @@ class ContinuousMonthIndexTest { val weeks = weekWindowFor(window, ws) window.forEach { index -> val ym = yearMonthForIndex(index) - val first = weekIndexOf(firstOfMonth(ym), ws) - val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws) + val first = weekIndexOf(ym.firstDay, ws) + val last = weekIndexOf(yearMonthForIndex(index + 1).firstDay.minus(1, DateTimeUnit.DAY), ws) assertThat(weeks.contains(first)).isTrue() assertThat(weeks.contains(last)).isTrue() } @@ -152,7 +153,7 @@ class ContinuousMonthIndexTest { val wed = LocalDate(2026, 6, 10) DayOfWeek.entries.forEach { 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(index).isLessThan(continuousWeekCount(ws)) assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) @@ -161,8 +162,8 @@ class ContinuousMonthIndexTest { } @Test - fun `firstOfMonth is the 1st`() { - assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) + fun `firstDay is the 1st`() { + assertThat(jun26.firstDay).isEqualTo(LocalDate(2026, 6, 1)) } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index c8a63f1..54b1df5 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.month +import de.jeanlucmakiola.calendula.ui.common.startOfWeek import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.allDayEvent import de.jeanlucmakiola.calendula.domain.EventResponse @@ -51,12 +52,12 @@ class MonthLayoutTest { ) = allDayEvent(from, toInclusive, id = id, title = title, color = 0xFF445566.toInt()) @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) - assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) - assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + assertThat(jun8.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) // 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 diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index c47c8c6..b37a2d6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.startOfWeek import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.testEvent import kotlinx.datetime.DateTimeUnit diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt index 0480c9f..9199ec4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.startOfWeek import com.google.common.truth.Truth.assertThat import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek