From f55cfffa3a1e7572183732bac8f5f0a0611add71 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 13:56:17 +0200 Subject: [PATCH] Extract repeated helpers in data, domain and UI layers (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 of the simplification review: mechanical helper extractions, one commit per group. Widget code is untouched. **Data layer** (CalendarDataSource and the mappers) - `updateEventRow`/`eventUri` replace the seven "update, throw if no row" copies and the loose `withAppendedId` spellings. - `SeriesRow.timeColumns()` / `exdateValues()` define the DTSTART/RRULE/DURATION/TZ/ALL_DAY set once for truncate, split, detach, delete and exception copy. - One `insertReminderRow` for the four reminder loops, `insertLocalCalendar` for local and managed creates, `applyManagedCalendarReminders` iterates `queryManagedEvents`, `reconcileAttendees` reuses `insertAttendees`, plus `mapRows`, `instancesUri`, `mintUid`, shared `eventColorColumns` and `switchedOffCalendarIds`. - The read-side mappers share `ColumnReader` helpers (end time, status, all-day UTC date); each keeps its own end-time clamp. `importDuration` goes through `rfc2445Duration` with identical output. The `eventColorPalette` data-source member is gone; the repository calls the pure function. - One behaviour difference: a provider *exception* while inserting a reminder row was only swallowed on import. It is now logged and skipped on every path (create, update, reconcile, move), as the existing comments already describe it, so it no longer fails the event write. **Domain** - `BasicFormat.kt` replaces the four `%04d…` formatters and three substring parsers (ICS writer/parser, Recurrence, RecurrenceText). Parsing stays as lenient as before. - `rruleParts()` for the RRULE tokenising in five places; each caller keeps its own malformed-part handling. `rruleTruncatedAt` keeps its own tokeniser since it preserves garbage parts verbatim. - `allDayFormTimes` and `normalizedRrule` for the three form-prefill copies, `zoneOrNull` for the eight zone guards, EventSearch orders months by `YearMonth`, IcsParser uses duration arithmetic and `firstOrNull`. - FREQ matching no longer depends on the device locale (a lowercase rule failed to parse on Turkish-locale phones). - Skipped: the contact-date parser rewrite (tests don't pin enough inputs) and the writer-side enum tables (their exhaustive `when` is a useful compile check). **UI / view models** - `dayRange(first, last, zone)` and one `startOfWeek` replace four start..end-of-day helpers and two identical week-start functions. - `orUnnamed()` replaces 17 `ifBlank { calendar_unnamed }` spellings; a shared `CalendarVisibilitySwitch` and `CalendarManagerRow`. - Filter uses `CalendarAccountGroup` plus a hidden-id set instead of its own copy of the grouping model; the grouping test moved to `AccountGroupsTest`. - `agendaDays` is shared by the Agenda screen and the split-month pane. - kotlinx-datetime's `yearMonth`, `firstDay`/`lastDay` and java.time converters replace about 20 hand-written bridges; one `formatTimeOfDay` for agenda rows and month chips. - No visible UI change. Net about −240 lines. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/416 --- .../data/calendar/CalendarDataSource.kt | 427 +++++++----------- .../data/calendar/CalendarRepository.kt | 2 +- .../data/calendar/CalendarRepositoryImpl.kt | 29 +- .../calendula/data/calendar/ColumnReader.kt | 35 ++ .../data/calendar/EventDetailMapper.kt | 27 +- .../data/calendar/EventWriteMapper.kt | 4 +- .../data/calendar/IcsExportMapper.kt | 29 +- .../calendula/data/calendar/InstanceMapper.kt | 6 +- .../calendula/data/calendar/Projections.kt | 9 + .../calendula/data/calendar/SearchMapper.kt | 13 +- .../data/reminders/ReminderInstanceSource.kt | 7 +- .../calendula/domain/EventForm.kt | 30 +- .../calendula/domain/EventSearch.kt | 53 +-- .../calendula/domain/EventShift.kt | 5 +- .../calendula/domain/InsertEventForm.kt | 15 +- .../calendula/domain/Recurrence.kt | 73 ++- .../calendula/domain/RecurrenceRealign.kt | 20 +- .../calendula/domain/ics/BasicFormat.kt | 47 ++ .../calendula/domain/ics/IcsParser.kt | 95 +--- .../calendula/domain/ics/IcsRecurrence.kt | 15 +- .../calendula/domain/ics/IcsWriter.kt | 38 +- .../calendula/domain/ics/ParsedIcsForm.kt | 13 +- .../calendula/ui/agenda/AgendaRange.kt | 9 +- .../calendula/ui/agenda/AgendaRows.kt | 81 +++- .../calendula/ui/agenda/AgendaScreen.kt | 51 +-- .../calendula/ui/agenda/AgendaViewModel.kt | 11 +- .../calendula/ui/calendars/BackupScreen.kt | 3 +- .../calendula/ui/calendars/CalendarsScreen.kt | 85 ++-- .../calendula/ui/common/AccountGroups.kt | 2 +- .../ui/common/CalendarPickerGroups.kt | 2 +- .../calendula/ui/common/CalendarText.kt | 6 + .../ui/common/CalendarVisibilitySwitch.kt | 24 + .../calendula/ui/common/DayRange.kt | 22 + .../calendula/ui/common/RecurrenceText.kt | 16 +- .../calendula/ui/common/TimeFormat.kt | 7 + .../calendula/ui/detail/EventDetailScreen.kt | 6 +- .../calendula/ui/edit/EventEditScreen.kt | 9 +- .../calendula/ui/filter/CalendarFilterList.kt | 26 +- .../calendula/ui/filter/FilterUiState.kt | 28 +- .../calendula/ui/filter/FilterViewModel.kt | 28 +- .../calendula/ui/month/MonthChipTime.kt | 4 +- .../calendula/ui/month/MonthScreen.kt | 66 ++- .../calendula/ui/month/MonthStylePreview.kt | 6 +- .../calendula/ui/month/MonthViewModel.kt | 73 ++- .../ui/onboarding/HiddenCalendarsStep.kt | 26 +- .../ui/settings/EventFormSettings.kt | 3 +- .../ui/settings/NotificationSettings.kt | 5 +- .../calendula/ui/settings/ViewsSettings.kt | 3 +- .../calendula/ui/week/WeekPaging.kt | 1 + .../calendula/ui/week/WeekScreen.kt | 8 +- .../calendula/ui/week/WeekViewModel.kt | 17 +- .../calendula/ui/week/WeekViewPreview.kt | 1 + .../calendar/CalendarRepositoryImplTest.kt | 5 +- .../data/calendar/FakeCalendarDataSource.kt | 3 - .../AccountGroupsTest.kt} | 47 +- .../ui/month/ContinuousMonthIndexTest.kt | 11 +- .../calendula/ui/month/MonthLayoutTest.kt | 9 +- .../calendula/ui/week/WeekLayoutTest.kt | 1 + .../calendula/ui/week/WeekPagingTest.kt | 1 + 59 files changed, 728 insertions(+), 970 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarVisibilitySwitch.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DayRange.kt rename app/src/test/java/de/jeanlucmakiola/calendula/ui/{filter/FilterGroupingTest.kt => common/AccountGroupsTest.kt} (59%) 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