diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 990832d..dd008ed 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -30,7 +30,10 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md` - Home-screen widget - Full-text search - Quick-add -- Custom notifications/reminders (system already handles these) +- ~~Custom notifications/reminders (system already handles these)~~ — + **reversed:** Calendula targets sole-calendar-app users, so no other app + posts reminder notifications. We post them ourselves (Etar model). Planned + for v1.4 — see `ROADMAP.md`. - Tablet/foldable-specific layouts - iOS support (Android-only by design) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e136c0d..70bab21 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -64,9 +64,35 @@ guide here, not a contract — scope per slice is decided as we go. | v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) | | v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) | | v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) | -| v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned | +| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) | +| v1.4 | Reminder notifications — see below | planned | | v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned | +## v1.4 — Reminder Notifications + +**Essential**, not nice-to-have: Calendula targets users for whom it is their +*only* calendar app, so reminder delivery can't be delegated to Google/OEM +Calendar. The calendar provider schedules reminders and broadcasts +`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible +notification — a calendar app must. We become that app (the Etar model). + +Scope: +- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER` + (data scheme `content://com.android.calendar`) — wakes us at reminder time, + no foreground service. +- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to + `METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated + notification channel; tap opens event detail. +- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding. +- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders + toggle, **default ON**, with copy warning that a second calendar app with + notifications on will cause duplicate reminders. Mirrored into Settings + (reversible). + +Deliberately deferred (add only if needed): +- Snooze / dismiss notification actions (Etar has them) +- Battery-optimization exemption prompt for delivery reliability + ## v3.0 — Power-User Features - Home-screen widget diff --git a/.planning/STATE.md b/.planning/STATE.md index a61457d..b88a347 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,11 +5,12 @@ ## Status **Milestone:** v2.0 — Write support (milestone 2, in progress) -**Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after -Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day). -Milestone 2 runs in four slices -(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3 -(edit event). Note: UI slices now hold release until his explicit approval. +**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device +review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip, +stale-instances split bugfix). Milestone 2 runs in four slices +(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add, +conflict dialog, polish) is the remaining slice, v1.4 (reminder +notifications) comes first. ## Progress @@ -44,8 +45,27 @@ Milestone 2 runs in four slices with provider-correct all-day normalisation (UTC midnights, exclusive end), domain/mapper/repository tests +- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for + edit (detail-screen Edit action, `canModify`-gated, contextual WRITE + upgrade), dirty-checked partial `update` on the Events row (recurring: + series DTSTART moves by the user's delta, DURATION instead of DTEND), + reminder diff by minutes (kept rows keep their method), simple recurrence + picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and + shown humanized), `EventFormField.Recurrence` incl. settings default, + recurrence also available on create; domain/mapper/repository tests. + Review round 1: weekly BYDAY day-toggles in the custom picker ("every week + on Mon+Fri"). Review rounds 2–4: occurrence edit pulled forward from v2.0 + and made three-way like delete ("this" = exception row via + `CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" = + series update); delete equally three-way (truncation via RRULE UNTIL); + the edit-scope question moved to save time (Google model) — dirty + recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops + the "only this event" option + ## Next -1. v1.3 — edit event: reuse the form, series edit, reminder edit, simple - recurrence picker -2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0 +1. v1.4 — reminder notifications (essential for sole-app use): `EVENT_REMINDER` + receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with + default-on toggle + duplicate-reminder warning (Etar model) +2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release +3. Monitor the F-Droid build/publish for v1.1.0 – v1.3.0 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 f24cc5c..0ed8f86 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 @@ -17,7 +17,9 @@ import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.Reminder +import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import java.time.ZoneId +import java.time.ZoneOffset import javax.inject.Inject import javax.inject.Singleton @@ -37,6 +39,44 @@ interface CalendarDataSource { /** Insert a new event; returns the new `Events._ID`. */ fun insertEvent(form: EventForm): Long + /** + * Update an existing event (for recurring events: the whole series) to + * match [updated]. [original] is the form as it was prefilled from the + * event, so only fields the user actually changed are written and the + * reminder rows can be diffed instead of wiped. + */ + fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) + + /** + * Change a single occurrence of a recurring event by inserting a + * modified-occurrence exception at [beginMillis] (the occurrence's + * `Instances.BEGIN`) carrying [form]'s values; returns the exception + * row's `Events._ID`. + */ + fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long + + /** + * Change a recurring event from the occurrence at [beginMillis] onwards + * by splitting the series: the existing RRULE ends just before the + * occurrence and a new event with [updated]'s values (and rule) starts + * there; returns the new event's `Events._ID`. From the first occurrence + * this is a plain series update. A carried-over COUNT restarts counting + * in the new series (we don't recompute the remaining occurrences). + */ + fun updateEventFromOccurrence( + eventId: Long, + beginMillis: Long, + original: EventForm, + updated: EventForm, + ): Long + + /** + * Delete a recurring event from the occurrence at [beginMillis] onwards + * by ending the series RRULE just before it. Deleting from the first + * occurrence removes the whole event. + */ + fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) + /** Delete the whole event (for recurring events: the entire series). */ fun deleteEvent(eventId: Long) @@ -101,7 +141,14 @@ class AndroidCalendarDataSource @Inject constructor( put(CalendarContract.Events.TITLE, form.title.trim()) put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) put(CalendarContract.Events.DTSTART, times.dtStartMillis) - put(CalendarContract.Events.DTEND, times.dtEndMillis) + // The provider's invariant: recurring rows carry RRULE+DURATION + // (and no DTEND), one-off rows carry DTEND. + if (form.rrule == null) { + put(CalendarContract.Events.DTEND, times.dtEndMillis) + } else { + put(CalendarContract.Events.RRULE, form.rrule) + put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay)) + } put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) @@ -128,6 +175,186 @@ class AndroidCalendarDataSource @Inject constructor( return eventId } + override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) { + val values = buildEventUpdateValues( + original = original, + updated = updated, + seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis, + 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") + } + // Untouched reminder sets are left alone so unrelated edits can't + // disturb provider rows the form never knew about. + if (updated.reminders.toSet() != original.reminders.toSet()) { + reconcileReminders(eventId, updated.reminders) + } + } + + override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long { + // The provider clones the series row and applies these values on top. + val values = buildOccurrenceExceptionValues( + form = form, + originalInstanceMillis = beginMillis, + zone = ZoneId.systemDefault(), + ) + val uri = resolver.insert( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId), + values.toContentValues(), + ) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis") + val exceptionId = ContentUris.parseId(uri) + // Whether the provider copied the parent's reminder rows is its + // business — reconciling against the actual rows handles both ways. + reconcileReminders(exceptionId, form.reminders) + return exceptionId + } + + override fun updateEventFromOccurrence( + eventId: Long, + beginMillis: Long, + original: EventForm, + updated: EventForm, + ): Long { + val row = querySeriesRow(eventId) + // From the first occurrence on (or with no rule to split) this is + // just a series update. + if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) { + updateEvent(eventId, original, updated) + return eventId + } + // Insert the new series first: if it fails, the original is untouched. + val newEventId = insertEvent(updated) + truncateSeries(eventId, row, beginMillis) + return newEventId + } + + override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) { + val row = querySeriesRow(eventId) + // From the first occurrence on = the whole series; also the fallback + // when there is no RRULE to truncate. + if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) { + deleteEvent(eventId) + return + } + truncateSeries(eventId, row, beginMillis) + } + + /** + * End [row]'s series just before the occurrence at [beginMillis]. The + * provider regenerates an event's cached instances only from the values + * carried by the update itself — an RRULE-only update leaves the old + * instances standing (observed on-device: the truncated occurrence kept + * showing) — so the entire time-related set travels together, with only + * the RRULE actually changing. + */ + 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) + 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") + } + } + + /** 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), + arrayOf( + CalendarContract.Events.DTSTART, + CalendarContract.Events.RRULE, + CalendarContract.Events.EVENT_TIMEZONE, + CalendarContract.Events.DURATION, + CalendarContract.Events.ALL_DAY, + ), + null, null, null, + )?.use { c -> + if (c.moveToFirst()) { + SeriesRow( + dtStartMillis = c.getLong(0), + rrule = c.getString(1), + timezone = c.getString(2), + duration = c.getString(3), + allDay = c.getInt(4), + ) + } else { + null + } + } ?: throw WriteFailedException("read series row of event id=$eventId") + + private data class SeriesRow( + val dtStartMillis: Long, + val rrule: String?, + val timezone: String?, + val duration: String?, + val allDay: Int, + ) { + /** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */ + fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis( + beginMillis = beginMillis, + zone = runCatching { ZoneId.of(timezone ?: "UTC") }.getOrDefault(ZoneOffset.UTC), + ) + } + + /** + * Make the event's reminder rows match [targetMinutes]: rows with other + * lead times are deleted, missing ones inserted as best-effort ALERTs + * (like insertEvent). Rows whose minutes survive keep their method. + */ + private fun reconcileReminders(eventId: Long, targetMinutes: List) { + val target = targetMinutes.toSet() + val existing = queryReminders(eventId).map { it.minutes }.toSet() + (existing - target).forEach { minutes -> + resolver.delete( + CalendarContract.Reminders.CONTENT_URI, + CalendarContract.Reminders.EVENT_ID + " = ? AND " + + CalendarContract.Reminders.MINUTES + " = ?", + 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") + } + } + } + + private fun Map.toContentValues(): ContentValues = + ContentValues().also { cv -> + forEach { (column, value) -> + when (value) { + null -> cv.putNull(column) + is String -> cv.put(column, value) + is Long -> cv.put(column, value) + is Int -> cv.put(column, value) + else -> error("Unsupported value for $column: $value") + } + } + } + override fun deleteEvent(eventId: Long) { val deleted = resolver.delete( ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), 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 ede6397..14bd5e5 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 @@ -15,11 +15,37 @@ interface CalendarRepository { /** Create a new event from a validated form; returns the new `Events._ID`. */ suspend fun createEvent(form: EventForm): Long + /** + * Update an event (recurring: the whole series) from a validated form. + * [original] is the prefilled form, used to write only what changed. + */ + suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) + + /** + * Change a single occurrence of a recurring event (exception row with the + * form's values); returns the exception's `Events._ID`. + */ + suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long + + /** + * Change a recurring event from [beginMillis] onwards (series split); + * returns the new event's `Events._ID`. + */ + suspend fun updateEventFromOccurrence( + eventId: Long, + beginMillis: Long, + original: EventForm, + updated: EventForm, + ): Long + /** Delete the whole event (for recurring events: the entire series). */ suspend fun deleteEvent(eventId: Long) /** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */ suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) + + /** Delete a recurring event from the occurrence at [beginMillis] onwards. */ + suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) } class NoSuchEventException(eventId: Long) : 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 5b6c3d3..c880d74 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 @@ -74,13 +74,45 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.insertEvent(form) } + override suspend fun updateEvent( + eventId: Long, + original: EventForm, + updated: EventForm, + ) = withContext(io) { + dataSource.updateEvent(eventId, original, updated) + } + override suspend fun deleteEvent(eventId: Long) = withContext(io) { dataSource.deleteEvent(eventId) } + override suspend fun updateOccurrence( + eventId: Long, + beginMillis: Long, + form: EventForm, + ): Long = withContext(io) { + dataSource.updateOccurrence(eventId, beginMillis, form) + } + + override suspend fun updateEventFromOccurrence( + eventId: Long, + beginMillis: Long, + original: EventForm, + updated: EventForm, + ): Long = withContext(io) { + dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated) + } + override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) { dataSource.deleteOccurrence(eventId, beginMillis) } + + override suspend fun deleteEventFromOccurrence( + eventId: Long, + beginMillis: Long, + ) = withContext(io) { + dataSource.deleteEventFromOccurrence(eventId, beginMillis) + } } private fun Flow.reQuery(block: suspend () -> T): Flow = flow { 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 af44024..d5372af 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 @@ -42,8 +42,9 @@ internal fun ColumnReader.toEventDetailCore( rawEnd } - val rawTitle = getString(EventDetailProjection.IDX_TITLE) - val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle + // Kept raw (no untitled fallback): the detail screen substitutes its own + // localized placeholder, and the edit form must prefill the true value. + val title = getString(EventDetailProjection.IDX_TITLE).orEmpty() val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { getInt(EventDetailProjection.IDX_CALENDAR_COLOR) 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 09dea01..4293ed2 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 @@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventForm import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDateTime +import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset @@ -37,6 +38,118 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa ) } +/** + * RFC 2445 duration for a recurring event's row (the provider requires + * DURATION instead of DTEND when an RRULE is set): whole days for all-day + * events, seconds otherwise. + */ +internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) { + "P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D" +} else { + "P${(dtEndMillis - dtStartMillis) / 1_000L}S" +} + +/** + * Dirty-checked column values for updating an existing Events row: only what + * the user actually changed is written, so untouched fields can't stomp + * concurrent external edits. Keys are `CalendarContract.Events` columns; a + * null value means "set the column to NULL". An empty map means nothing on + * the row changed. + * + * Time fields travel together (the provider validates them as a unit): + * - unchanged times, all-day flag and rrule → no time columns at all; + * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; + * - recurring result → the *series* DTSTART moves by the same delta the user + * applied to the displayed occurrence ([seriesDtStartMillis] is the row's + * current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps + * past occurrences intact when someone edits a later occurrence's time. + */ +internal fun buildEventUpdateValues( + original: EventForm, + updated: EventForm, + seriesDtStartMillis: Long, + zone: ZoneId, +): Map = buildMap { + if (updated.title.trim() != original.title.trim()) { + put(CalendarContract.Events.TITLE, updated.title.trim()) + } + if (updated.location.trim() != original.location.trim()) { + put(CalendarContract.Events.EVENT_LOCATION, updated.location.trim().ifEmpty { null }) + } + if (updated.description.trim() != original.description.trim()) { + put(CalendarContract.Events.DESCRIPTION, updated.description.trim().ifEmpty { null }) + } + if (updated.availability != original.availability) { + put(CalendarContract.Events.AVAILABILITY, updated.availability.toProviderValue()) + } + if (updated.accessLevel != original.accessLevel) { + put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue()) + } + + val timesChanged = updated.start != original.start || + updated.end != original.end || + updated.isAllDay != original.isAllDay || + updated.rrule != original.rrule + if (!timesChanged) return@buildMap + + val newTimes = updated.toWriteTimes(zone) + put(CalendarContract.Events.ALL_DAY, if (updated.isAllDay) 1 else 0) + put(CalendarContract.Events.EVENT_TIMEZONE, newTimes.timezone) + if (updated.rrule == null) { + put(CalendarContract.Events.DTSTART, newTimes.dtStartMillis) + put(CalendarContract.Events.DTEND, newTimes.dtEndMillis) + put(CalendarContract.Events.RRULE, null) + put(CalendarContract.Events.DURATION, null) + } else { + val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis + put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta) + put(CalendarContract.Events.DTEND, null) + put(CalendarContract.Events.RRULE, updated.rrule) + put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) + } +} + +/** + * Column values for a modified-occurrence exception row ("edit only this + * event"): inserting them at `Events.CONTENT_EXCEPTION_URI/` makes the + * provider clone the series row and apply these on top. Unlike the series + * update there is no dirty check — the exception is a fresh row, so every + * form-backed column is written (empty optionals as explicit NULLs, since the + * clone starts from the parent's values). An exception is a single event: + * DTEND, never RRULE/DURATION. + */ +internal fun buildOccurrenceExceptionValues( + form: EventForm, + originalInstanceMillis: Long, + zone: ZoneId, +): Map = buildMap { + val times = form.toWriteTimes(zone) + put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis) + put(CalendarContract.Events.TITLE, form.title.trim()) + put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, times.dtStartMillis) + put(CalendarContract.Events.DTEND, times.dtEndMillis) + put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) + put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) + put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) + put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) + put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) +} + +/** + * UTC millis of the last second of the local day *before* the occurrence at + * [beginMillis] in [zone] — the cutoff for truncating a series with UNTIL. + * The provider's recurrence engine applies UNTIL coarsely (observed on a + * Pixel: an occurrence one second *after* UNTIL was still generated), so the + * series must end on the previous day, not one second before the occurrence. + * With no sub-daily frequencies that is semantically the same cut. + */ +internal fun previousLocalDayEndUtcMillis(beginMillis: Long, zone: ZoneId): Long = + Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate() + .atStartOfDay(zone).toInstant().toEpochMilli() - 1_000L + +private const val MILLIS_PER_DAY = 86_400_000L + internal fun Availability.toProviderValue(): Int = when (this) { Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE 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 3f818fe..19dba3f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -1,6 +1,11 @@ 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.Instant /** * User input for creating an event (and, from v1.3, editing one). Times are @@ -19,6 +24,12 @@ data class EventForm( val reminders: List = emptyList(), val availability: Availability = Availability.Busy, val accessLevel: AccessLevel = AccessLevel.Default, + /** + * Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null + * means a one-off event. May hold rules the simple picker can't express — + * those are kept verbatim until the user picks something else. + */ + val rrule: String? = null, ) /** @@ -29,6 +40,7 @@ enum class EventFormField { Location, Description, Reminders, + Recurrence, Availability, Visibility, } @@ -37,6 +49,8 @@ enum class EventFormProblem { /** No target calendar — none picked and no writable calendar exists. */ NoCalendar, EndBeforeStart, + /** The recurrence's UNTIL date lies before the event's first day. */ + RecurrenceEndsBeforeStart, } /** @@ -44,8 +58,64 @@ enum class EventFormProblem { * allowed (display falls back to "(No title)", matching the provider), and a * zero-length timed event is allowed (spec §8: instant events exist). */ +/** + * Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the + * tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series + * start — the data layer later turns a time edit into a delta on the series. + * + * All-day provider times are UTC midnights with an exclusive end; the form + * shows the last covered day and keeps placeholder wall-clock times in case + * the user switches the event to timed. + */ +fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { + val (start, end) = if (instance.isAllDay) { + val startDate = Instant.fromEpochMilliseconds(beginMillis) + .toLocalDateTime(TimeZone.UTC).date + val endExclusive = Instant.fromEpochMilliseconds(endMillis) + .toLocalDateTime(TimeZone.UTC).date + val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1)) + LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) + } else { + Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to + Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone) + } + return EventForm( + calendarId = instance.calendarId, + title = instance.title, + isAllDay = instance.isAllDay, + start = start, + end = end, + location = instance.location.orEmpty(), + description = description.orEmpty(), + reminders = reminders.map { it.minutes }.distinct().sorted(), + availability = availability, + accessLevel = accessLevel, + rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, + ) +} + +/** + * The optional sections that hold a value in [form] — when editing, these + * must be visible regardless of the user's default-fields setting, or the + * data they carry would be invisible (though still preserved). + */ +fun EventForm.populatedFields(): Set = buildSet { + if (location.isNotBlank()) add(EventFormField.Location) + if (description.isNotBlank()) add(EventFormField.Description) + if (reminders.isNotEmpty()) add(EventFormField.Reminders) + if (rrule != null) add(EventFormField.Recurrence) + if (availability != Availability.Busy) add(EventFormField.Availability) + if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) +} + fun EventForm.problems(): Set = buildSet { if (calendarId == null) add(EventFormProblem.NoCalendar) val endsTooEarly = if (isAllDay) end.date < start.date else end < start if (endsTooEarly) add(EventFormProblem.EndBeforeStart) + // An UNTIL before the first day would make the provider generate zero + // occurrences — the event would silently vanish from every view. + val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end + if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) { + add(EventFormProblem.RecurrenceEndsBeforeStart) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 67b8559..21d0581 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -115,6 +115,16 @@ enum class AccessLevel { Confidential, } +/** + * How far a write to a recurring event reaches. Non-recurring events always + * use [AllEvents] (there is only one). + */ +enum class RecurringWriteScope { + ThisEvent, + ThisAndFollowing, + AllEvents, +} + enum class FailureReason { PermissionRevoked, NoCalendarsConfigured, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt new file mode 100644 index 0000000..5090ac9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt @@ -0,0 +1,197 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.number +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** + * The recurrence shapes the simple picker can express (v1.3): a frequency, + * an interval, weekly weekday picks, and an optional end. Anything beyond + * that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw + * RRULE string the picker shows as "custom" and leaves untouched unless the + * user replaces it. + */ +data class SimpleRecurrence( + val freq: RecurrenceFreq, + val interval: Int = 1, + val end: RecurrenceEnd = RecurrenceEnd.Never, + /** + * Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means + * no BYDAY part — the provider derives the day from DTSTART. + */ + val byDays: Set = emptySet(), +) + +enum class RecurrenceFreq { + Daily, + Weekly, + Monthly, + Yearly, +} + +sealed interface RecurrenceEnd { + data object Never : RecurrenceEnd + + /** Last day on which an occurrence may fall (inclusive). */ + data class Until(val date: LocalDate) : RecurrenceEnd + + /** Total number of occurrences, counting the first. */ + data class Count(val times: Int) : RecurrenceEnd +} + +/** + * Parse an RRULE into the picker's simple shape, or null when the rule uses + * parts the picker can't represent (so the UI preserves the original string). + * Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime + * UNTIL is converted from UTC into [zone] before its date is taken, mirroring + * [toRRule]. + */ +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) + } + 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 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 + // forms ("2TH" = second Thursday) and BYDAY on other frequencies are not. + val byDays = parts["BYDAY"]?.let { raw -> + if (freq != RecurrenceFreq.Weekly) return null + raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet() + } ?: emptySet() + + val until = parts["UNTIL"] + val count = parts["COUNT"] + if (until != null && count != null) return null + val end = when { + until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null) + count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null) + else -> RecurrenceEnd.Never + } + return SimpleRecurrence(freq, interval, end, byDays) +} + +/** + * Render as a provider-ready RRULE value (no "RRULE:" prefix — + * `CalendarContract.Events.RRULE` stores the bare value). UNTIL is written as + * the end of the chosen day *in [zone]*, expressed in UTC: the recurrence + * engine has been observed applying UNTIL coarsely after converting it into + * the event's timezone, so a plain `T235959Z` can leak one extra day for + * zones ahead of UTC. + */ +fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString { + append("FREQ=") + append( + when (freq) { + RecurrenceFreq.Daily -> "DAILY" + RecurrenceFreq.Weekly -> "WEEKLY" + RecurrenceFreq.Monthly -> "MONTHLY" + RecurrenceFreq.Yearly -> "YEARLY" + }, + ) + if (interval > 1) append(";INTERVAL=$interval") + if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) { + append(";BYDAY=") + append( + byDays.sortedBy { it.isoDayNumber } + .joinToString(",") { RRULE_DAY_CODES.getValue(it) }, + ) + } + 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, + ), + ) + } + is RecurrenceEnd.Count -> append(";COUNT=${e.times}") + } +} + +private val RRULE_DAY_CODES: Map = mapOf( + DayOfWeek.MONDAY to "MO", + DayOfWeek.TUESDAY to "TU", + DayOfWeek.WEDNESDAY to "WE", + DayOfWeek.THURSDAY to "TH", + DayOfWeek.FRIDAY to "FR", + DayOfWeek.SATURDAY to "SA", + DayOfWeek.SUNDAY to "SU", +) + +/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */ +private fun rruleDay(token: String): DayOfWeek? = + RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key + +/** + * End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing + * UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives. + * Used for "delete this and all following occurrences" — the caller passes a + * moment just before the first occurrence to remove. + */ +fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String { + val kept = rrule.removePrefix("RRULE:").split(';') + .filter { it.isNotBlank() } + .filterNot { part -> + 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, + ) + return (kept + untilPart).joinToString(";") +} + +/** + * Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime + * 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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 9c046e0..8d95d2c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -76,6 +76,15 @@ fun CalendarHost(modifier: Modifier = Modifier) { createDateIso = date.toString() } + // Edit form (v1.3) — reuses the detail screen's occurrence key; for + // recurring events the form itself asks for the write scope at save + // time. A saved edit closes the detail screen too: the occurrence the + // user tapped may not exist anymore (time moved, recurrence changed), so + // falling back to the auto-refreshing calendar is the only honest + // destination. + var editKey by rememberSaveable { mutableStateOf(null) } + var heldEditKey by remember { mutableStateOf(null) } + val slideSpec = rememberCalendarSlideSpec() Box(modifier = modifier.fillMaxSize()) { @@ -117,6 +126,10 @@ fun CalendarHost(modifier: Modifier = Modifier) { beginMillis = key[1], endMillis = key[2], onBack = { detailKey = null }, + onEdit = { + heldEditKey = key + editKey = key + }, ) } } @@ -131,6 +144,26 @@ fun CalendarHost(modifier: Modifier = Modifier) { EventEditScreen( initialDateIso = iso, onClose = { createDateIso = null }, + onSaved = { createDateIso = null }, + ) + } + } + + // Edit form (v1.3) — slides over the detail screen. + AnimatedVisibility( + visible = editKey != null, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + (editKey ?: heldEditKey)?.let { key -> + EventEditScreen( + initialDateIso = null, + editKey = key, + onClose = { editKey = null }, + onSaved = { + editKey = null + detailKey = null + }, ) } } 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 new file mode 100644 index 0000000..2c930b8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt @@ -0,0 +1,126 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.icu.text.ListFormatter +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import de.jeanlucmakiola.calendula.R +import java.time.DayOfWeek +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale + +/** + * Humanise an RFC 5545 RRULE into a localized phrase, e.g. + * "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times". + * Falls back to a generic label for rules we don't render in full (ordinal + * monthly/yearly BYDAY, etc.). Shared by the detail screen and the edit + * form's repeat card. + */ +@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 freq = parts["FREQ"]?.uppercase() + val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 + val base = when (freq) { + "DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily) + else stringResource(R.string.recurrence_every_n_days, interval) + "WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly) + else stringResource(R.string.recurrence_every_n_weeks, interval) + "MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly) + else stringResource(R.string.recurrence_every_n_months, interval) + "YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly) + else stringResource(R.string.recurrence_every_n_years, interval) + else -> return AnnotatedString(stringResource(R.string.event_detail_recurring)) + } + + // Weekly + BYDAY → " on "; other BYDAY forms keep just the base. + // The day names + their joined block are tracked so only the names (not the + // commas/conjunction) can be italicised in the final string. + val byDay = parts["BYDAY"] + var dayNames: List? = null + var joinedDays: String? = null + val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) { + val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) } + if (days.isNotEmpty()) { + val joined = ListFormatter.getInstance(locale).format(days) + dayNames = days + joinedDays = joined + stringResource(R.string.recurrence_on_days, base, joined) + } else { + base + } + } else { + base + } + + // End bound: UNTIL (a date) takes precedence over COUNT (a number of times). + val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) } + val count = parts["COUNT"]?.toIntOrNull() + val full = when { + until != null -> stringResource(R.string.recurrence_with_until, main, until) + count != null -> stringResource(R.string.recurrence_with_count, main, count) + else -> main + } + + return buildAnnotatedString { + append(full) + val names = dayNames + val joined = joinedDays + if (names != null && joined != null) { + // Italicise each day name within the joined block only — leaving the + // separators and conjunction ("und"/"and") in the regular style. + val regionStart = full.indexOf(joined) + if (regionStart >= 0) { + val regionEnd = regionStart + joined.length + var cursor = regionStart + for (name in names) { + val at = full.indexOf(name, cursor) + if (at in regionStart until regionEnd) { + addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length) + cursor = at + name.length + } + } + } + } + } +} + +/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */ +private fun rruleDayName(token: String, locale: Locale): String? { + val dow = when (token.takeLast(2).uppercase()) { + "MO" -> DayOfWeek.MONDAY + "TU" -> DayOfWeek.TUESDAY + "WE" -> DayOfWeek.WEDNESDAY + "TH" -> DayOfWeek.THURSDAY + "FR" -> DayOfWeek.FRIDAY + "SA" -> DayOfWeek.SATURDAY + "SU" -> DayOfWeek.SUNDAY + else -> return null + } + return dow.getDisplayName(JavaTextStyle.SHORT, locale) +} + +/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */ +private fun parseUntilDate(raw: String, locale: Locale): String? { + val digits = raw.takeWhile { it.isDigit() } + if (digits.length < 8) return null + return try { + val date = java.time.LocalDate.of( + digits.substring(0, 4).toInt(), + digits.substring(4, 6).toInt(), + digits.substring(6, 8).toInt(), + ) + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) + } catch (e: Exception) { + null + } +} 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 3a67000..33dcc2d 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 @@ -5,7 +5,6 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.icu.text.ListFormatter import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -35,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Place @@ -74,7 +74,6 @@ import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Dp @@ -90,13 +89,14 @@ import de.jeanlucmakiola.calendula.domain.AttendeeType import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.recurrenceText import kotlinx.datetime.TimeZone -import java.time.DayOfWeek import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -109,7 +109,9 @@ import kotlin.time.Instant * Full-screen event detail (spec S4, realised as a navigation destination * rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the * top-bar arrow both return to the calendar. Events in writable calendars can - * be deleted from here (v1.1); edit follows in v1.3. + * be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared + * event form for this occurrence — for recurring events the form asks how + * far the change reaches when saving. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -118,6 +120,7 @@ fun EventDetailScreen( beginMillis: Long, endMillis: Long, onBack: () -> Unit, + onEdit: () -> Unit, viewModel: EventDetailViewModel = hiltViewModel(), ) { LaunchedEffect(eventId, beginMillis, endMillis) { @@ -133,20 +136,35 @@ fun EventDetailScreen( var showDeleteDialog by rememberSaveable { mutableStateOf(false) } // v1.0 installs only hold READ_CALENDAR; the first write asks for the - // upgrade in place. Granting continues straight into the confirm dialog. + // upgrade in place. Granting continues straight into the tapped action. + var pendingEdit by remember { mutableStateOf(false) } val writePermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> - if (granted) showDeleteDialog = true + if (granted) { + if (pendingEdit) onEdit() else showDeleteDialog = true + } + pendingEdit = false } - val onDeleteClick = { - val granted = ContextCompat.checkSelfPermission( + val hasWritePermission = { + ContextCompat.checkSelfPermission( context, Manifest.permission.WRITE_CALENDAR, ) == PackageManager.PERMISSION_GRANTED - if (granted) { + } + val onDeleteClick = { + if (hasWritePermission()) { showDeleteDialog = true } else { + pendingEdit = false + writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) + } + } + val onEditClick = { + if (hasWritePermission()) { + onEdit() + } else { + pendingEdit = true writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) } } @@ -189,6 +207,15 @@ fun EventDetailScreen( // birthday calendars etc. are read-only at the provider level. val s = state if (s is EventDetailUiState.Success && s.canModify) { + IconButton( + onClick = onEditClick, + enabled = deleteState != DeleteUiState.Deleting, + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.event_detail_edit), + ) + } IconButton( onClick = onDeleteClick, enabled = deleteState != DeleteUiState.Deleting, @@ -223,9 +250,9 @@ fun EventDetailScreen( if (showDeleteDialog && loaded is EventDetailUiState.Success) { DeleteEventDialog( isRecurring = !loaded.detail.rrule.isNullOrBlank(), - onConfirm = { wholeSeries -> + onConfirm = { scope -> showDeleteDialog = false - viewModel.delete(wholeSeries) + viewModel.delete(scope) }, onDismiss = { showDeleteDialog = false }, ) @@ -234,15 +261,16 @@ fun EventDetailScreen( /** * Delete confirmation. Recurring events choose between cancelling just the - * tapped occurrence (default) and removing the whole series. + * tapped occurrence (default), truncating the series from it onwards, and + * removing the whole series. */ @Composable private fun DeleteEventDialog( isRecurring: Boolean, - onConfirm: (wholeSeries: Boolean) -> Unit, + onConfirm: (RecurringWriteScope) -> Unit, onDismiss: () -> Unit, ) { - var wholeSeries by rememberSaveable { mutableStateOf(false) } + var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) } AlertDialog( onDismissRequest = onDismiss, title = { @@ -258,13 +286,18 @@ private fun DeleteEventDialog( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OptionCard( label = stringResource(R.string.event_delete_option_occurrence), - onClick = { wholeSeries = false }, - selected = !wholeSeries, + onClick = { scope = RecurringWriteScope.ThisEvent }, + selected = scope == RecurringWriteScope.ThisEvent, + ) + OptionCard( + label = stringResource(R.string.event_delete_option_following), + onClick = { scope = RecurringWriteScope.ThisAndFollowing }, + selected = scope == RecurringWriteScope.ThisAndFollowing, ) OptionCard( label = stringResource(R.string.event_delete_option_series), - onClick = { wholeSeries = true }, - selected = wholeSeries, + onClick = { scope = RecurringWriteScope.AllEvents }, + selected = scope == RecurringWriteScope.AllEvents, ) } } else { @@ -272,7 +305,9 @@ private fun DeleteEventDialog( } }, confirmButton = { - TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) { + TextButton( + onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) }, + ) { Text( text = stringResource(R.string.event_detail_delete), color = MaterialTheme.colorScheme.error, @@ -707,116 +742,6 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remem } } -/** - * Humanise an RFC 5545 RRULE into a localized phrase, e.g. - * "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times". - * Falls back to a generic label for rules we don't render in full (ordinal - * monthly/yearly BYDAY, etc.). - */ -@Composable -private 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 freq = parts["FREQ"]?.uppercase() - val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 - val base = when (freq) { - "DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily) - else stringResource(R.string.recurrence_every_n_days, interval) - "WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly) - else stringResource(R.string.recurrence_every_n_weeks, interval) - "MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly) - else stringResource(R.string.recurrence_every_n_months, interval) - "YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly) - else stringResource(R.string.recurrence_every_n_years, interval) - else -> return AnnotatedString(stringResource(R.string.event_detail_recurring)) - } - - // Weekly + BYDAY → " on "; other BYDAY forms keep just the base. - // The day names + their joined block are tracked so only the names (not the - // commas/conjunction) can be italicised in the final string. - val byDay = parts["BYDAY"] - var dayNames: List? = null - var joinedDays: String? = null - val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) { - val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) } - if (days.isNotEmpty()) { - val joined = ListFormatter.getInstance(locale).format(days) - dayNames = days - joinedDays = joined - stringResource(R.string.recurrence_on_days, base, joined) - } else { - base - } - } else { - base - } - - // End bound: UNTIL (a date) takes precedence over COUNT (a number of times). - val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) } - val count = parts["COUNT"]?.toIntOrNull() - val full = when { - until != null -> stringResource(R.string.recurrence_with_until, main, until) - count != null -> stringResource(R.string.recurrence_with_count, main, count) - else -> main - } - - return buildAnnotatedString { - append(full) - val names = dayNames - val joined = joinedDays - if (names != null && joined != null) { - // Italicise each day name within the joined block only — leaving the - // separators and conjunction ("und"/"and") in the regular style. - val regionStart = full.indexOf(joined) - if (regionStart >= 0) { - val regionEnd = regionStart + joined.length - var cursor = regionStart - for (name in names) { - val at = full.indexOf(name, cursor) - if (at in regionStart until regionEnd) { - addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length) - cursor = at + name.length - } - } - } - } - } -} - -/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */ -private fun rruleDayName(token: String, locale: Locale): String? { - val dow = when (token.takeLast(2).uppercase()) { - "MO" -> DayOfWeek.MONDAY - "TU" -> DayOfWeek.TUESDAY - "WE" -> DayOfWeek.WEDNESDAY - "TH" -> DayOfWeek.THURSDAY - "FR" -> DayOfWeek.FRIDAY - "SA" -> DayOfWeek.SATURDAY - "SU" -> DayOfWeek.SUNDAY - else -> return null - } - return dow.getDisplayName(JavaTextStyle.SHORT, locale) -} - -/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */ -private fun parseUntilDate(raw: String, locale: Locale): String? { - val digits = raw.takeWhile { it.isDigit() } - if (digits.length < 8) return null - return try { - val date = java.time.LocalDate.of( - digits.substring(0, 4).toInt(), - digits.substring(4, 6).toInt(), - digits.substring(6, 8).toInt(), - ) - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) - } catch (e: Exception) { - null - } -} - /** * Format an event's time into a primary line (date, or "All day") and an * optional secondary line (time range). Multi-day timed events collapse into a diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index e8a709a..a969140 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -78,20 +79,23 @@ class EventDetailViewModel @Inject constructor( } /** - * Delete the open event. [wholeSeries] is meaningful only for recurring - * events: false cancels just the tapped occurrence. Result lands in - * [deleteState]; the screen consumes it via [consumeDeleteResult]. + * Delete the open event. [scope] is meaningful only for recurring events + * (one-off events always pass [RecurringWriteScope.AllEvents]). Result + * lands in [deleteState]; the screen consumes it via [consumeDeleteResult]. */ - fun delete(wholeSeries: Boolean) { + fun delete(scope: RecurringWriteScope) { val target = _target.value ?: return if (_deleteState.value == DeleteUiState.Deleting) return viewModelScope.launch { _deleteState.value = DeleteUiState.Deleting _deleteState.value = try { - if (wholeSeries) { - repository.deleteEvent(target.eventId) - } else { - repository.deleteOccurrence(target.eventId, target.beginMillis) + when (scope) { + RecurringWriteScope.AllEvents -> + repository.deleteEvent(target.eventId) + RecurringWriteScope.ThisEvent -> + repository.deleteOccurrence(target.eventId, target.beginMillis) + RecurringWriteScope.ThisAndFollowing -> + repository.deleteEventFromOccurrence(target.eventId, target.beginMillis) } DeleteUiState.Deleted } catch (e: CancellationException) { 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 9fa9caf..741df1f 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions @@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.VisibilityOff @@ -88,6 +90,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -101,49 +104,82 @@ import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormProblem +import de.jeanlucmakiola.calendula.domain.RecurrenceEnd +import de.jeanlucmakiola.calendula.domain.RecurrenceFreq +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.SimpleRecurrence +import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence +import de.jeanlucmakiola.calendula.domain.toRRule import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.recurrenceText +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.time.format.TextStyle as JavaTextStyle +import java.time.temporal.WeekFields import java.util.Locale import kotlin.time.Clock /** - * Full-screen event form (v1.2: create only). Opens prefilled from the FAB's - * anchor date; Save validates, writes via the repository, and closes. The - * calendar picker offers only writable calendars. + * Full-screen event form: create (v1.2, from the FAB's anchor date) and edit + * (v1.3, [editKey] = eventId + the tapped occurrence's begin/end millis). + * Save validates and writes via the repository — a dirty recurring event + * first asks how far the change reaches (this occurrence / this and + * following / whole series) — then closes. The calendar picker offers only + * writable calendars; when editing, the calendar is fixed. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun EventEditScreen( initialDateIso: String?, onClose: () -> Unit, + onSaved: () -> Unit, + editKey: LongArray? = null, viewModel: EventEditViewModel = hiltViewModel(), ) { - LaunchedEffect(initialDateIso) { - val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } - ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - viewModel.openNew(date) + LaunchedEffect(initialDateIso, editKey) { + if (editKey != null) { + viewModel.openForEdit( + eventId = editKey[0], + beginMillis = editKey[1], + endMillis = editKey[2], + ) + } else { + val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + viewModel.openNew(date) + } } val state by viewModel.state.collectAsStateWithLifecycle() + val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle() // The form is intentionally forgotten on every close (cancel or save) so - // the next FAB tap starts clean; it survives rotation because openNew - // no-ops while a form is set. + // the next open starts clean; it survives rotation because openNew / + // openForEdit no-op while a form is set. val close = { viewModel.reset() onClose() } BackHandler(onBack = close) + // The event vanished between the detail screen and the edit tap — fall + // back to the detail screen, which shows its own failure state. + LaunchedEffect(loadFailed) { + if (loadFailed) close() + } + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } @@ -169,7 +205,10 @@ fun EventEditScreen( val writeDeniedMessage = stringResource(R.string.event_edit_write_denied) LaunchedEffect(state?.saveState) { when (state?.saveState) { - SaveUiState.Saved -> close() + SaveUiState.Saved -> { + viewModel.reset() + onSaved() + } SaveUiState.Failed -> { viewModel.consumeSaveResult() snackbarHostState.showSnackbar(saveFailedMessage) @@ -178,7 +217,7 @@ fun EventEditScreen( viewModel.consumeSaveResult() snackbarHostState.showSnackbar(writeDeniedMessage) } - SaveUiState.Idle, SaveUiState.Saving, null -> Unit + SaveUiState.Idle, SaveUiState.AwaitingScope, SaveUiState.Saving, null -> Unit } } @@ -222,6 +261,53 @@ fun EventEditScreen( ) } } + + if (state?.saveState == SaveUiState.AwaitingScope) { + SaveScopeDialog( + recurrenceChanged = state?.recurrenceChanged == true, + onSelect = viewModel::saveWithScope, + onDismiss = viewModel::consumeSaveResult, + ) + } +} + +/** + * Scope choice when saving a dirty recurring event: one tap writes just the + * tapped occurrence, it and everything after (series split), or the whole + * series. A changed recurrence rule rules out the single occurrence — an + * exception row can't carry its own rule. + */ +@Composable +private fun SaveScopeDialog( + recurrenceChanged: Boolean, + onSelect: (RecurringWriteScope) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.event_edit_recurring_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!recurrenceChanged) { + OptionCard( + label = stringResource(R.string.event_delete_option_occurrence), + onClick = { onSelect(RecurringWriteScope.ThisEvent) }, + ) + } + OptionCard( + label = stringResource(R.string.event_delete_option_following), + onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) }, + ) + OptionCard( + label = stringResource(R.string.event_delete_option_series), + onClick = { onSelect(RecurringWriteScope.AllEvents) }, + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) } private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime } @@ -261,6 +347,7 @@ private fun EventEditContent( var picker by remember { mutableStateOf(null) } var showCalendarPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) } + var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } @@ -348,12 +435,15 @@ private fun EventEditContent( Spacer(Modifier.height(gap)) - // Calendar card — tap anywhere to pick the target calendar. + // Calendar card — tap anywhere to pick the target calendar. Editing + // keeps the owning calendar (moving events between calendars is a + // sync-adapter minefield; every stock calendar app locks it too). EditCard( icon = Icons.Default.CalendarMonth, iconContentDescription = stringResource(R.string.event_detail_calendar), iconTint = accent, - onClick = { showCalendarPicker = true }.takeIf { state.calendars.isNotEmpty() }, + onClick = { showCalendarPicker = true } + .takeIf { state.calendars.isNotEmpty() && !state.isEditing }, ) { Text( text = selectedCalendar?.displayName @@ -438,6 +528,46 @@ private fun EventEditContent( } } + OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.Repeat, + iconContentDescription = null, + onClick = { showRecurrencePicker = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = form.rrule?.let { recurrenceText(it, locale) } + ?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.event_detail_recurrence), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_error_recurrence_ends_before_start), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) { Spacer(Modifier.height(gap)) EditCard( @@ -497,7 +627,7 @@ private fun EventEditContent( } } - OptionalFormSection(visible = state.hasHiddenFields) { + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( onClick = { showFieldPicker = true }, @@ -561,6 +691,18 @@ private fun EventEditContent( ) } + if (showRecurrencePicker) { + RecurrencePickerDialog( + current = form.rrule, + startDay = form.start.date.dayOfWeek, + onSelect = { rrule -> + viewModel.setRecurrence(rrule) + showRecurrencePicker = false + }, + onDismiss = { showRecurrencePicker = false }, + ) + } + if (showVisibilityPicker) { VisibilityPickerDialog( selected = form.accessLevel, @@ -574,7 +716,7 @@ private fun EventEditContent( if (showFieldPicker) { FieldPickerDialog( - hiddenFields = EventFormField.entries.filterNot { it in state.visibleFields }, + hiddenFields = state.hiddenFields, onSelect = { field -> viewModel.revealField(field) showFieldPicker = false @@ -660,71 +802,17 @@ private fun ReminderPickerDialog( } } else { Row(verticalAlignment = Alignment.CenterVertically) { - // surfaceContainerHighest — the dialog itself sits on - // surfaceContainerHigh, so anything lower vanishes. - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = RoundedCornerShape(12.dp), - ) { - InlineField( - value = amountText, - onValueChange = { text -> - if (text.length <= 3 && text.all(Char::isDigit)) { - amountText = text - } - }, - placeholder = "10", - textStyle = MaterialTheme.typography.titleMedium, - keyboardType = KeyboardType.Number, - modifier = Modifier - .width(72.dp) - .padding(horizontal = 14.dp, vertical = 12.dp), - ) - } + DialogAmountField( + value = amountText, + onValueChange = { amountText = it }, + placeholder = "10", + ) Spacer(Modifier.width(12.dp)) - var unitMenuOpen by remember { mutableStateOf(false) } - Box { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = RoundedCornerShape(12.dp), - onClick = { unitMenuOpen = true }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding( - start = 14.dp, - end = 8.dp, - top = 12.dp, - bottom = 12.dp, - ), - ) { - Text( - text = stringResource(reminderUnitLabel(unit)), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.width(4.dp)) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - DropdownMenu( - expanded = unitMenuOpen, - onDismissRequest = { unitMenuOpen = false }, - ) { - ReminderUnit.entries.forEach { entry -> - DropdownMenuItem( - text = { Text(stringResource(reminderUnitLabel(entry))) }, - onClick = { - unit = entry - unitMenuOpen = false - }, - ) - } - } - } + DialogUnitDropdown( + label = stringResource(reminderUnitLabel(unit)), + entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) }, + onPick = { unit = ReminderUnit.entries[it] }, + ) } } }, @@ -743,6 +831,351 @@ private fun ReminderPickerDialog( ) } +/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */ +private enum class RecurrenceEndMode { Never, Until, Count } + +/** + * Recurrence picker, two steps like the reminder picker: the plain + * frequencies as a tappable list (one tap applies and closes), with "Custom" + * switching to an interval-plus-unit editor, weekday toggles (weekly only) + * and an end condition — only that step needs an OK button. A rule the + * simple shape can't express (ordinal BYDAY etc.) stays untouched unless the + * user picks something here. + */ +@Composable +private fun RecurrencePickerDialog( + current: String?, + startDay: DayOfWeek, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + val parsed = remember(current) { current?.let(::parseSimpleRecurrence) } + val isPlainPreset = parsed != null && parsed.interval == 1 && + parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty() + var customMode by rememberSaveable { mutableStateOf(false) } + var intervalText by rememberSaveable { mutableStateOf((parsed?.interval ?: 1).toString()) } + var freq by rememberSaveable { mutableStateOf(parsed?.freq ?: RecurrenceFreq.Weekly) } + // The event's start weekday stands in until the user picks days herself. + var daysMask by rememberSaveable { + mutableStateOf( + (parsed?.byDays?.takeIf { it.isNotEmpty() } ?: setOf(startDay)).toMask(), + ) + } + var endMode by rememberSaveable { + mutableStateOf( + when (parsed?.end) { + is RecurrenceEnd.Until -> RecurrenceEndMode.Until + is RecurrenceEnd.Count -> RecurrenceEndMode.Count + else -> RecurrenceEndMode.Never + }, + ) + } + var untilIso by rememberSaveable { + mutableStateOf((parsed?.end as? RecurrenceEnd.Until)?.date?.toString()) + } + var countText by rememberSaveable { + mutableStateOf(((parsed?.end as? RecurrenceEnd.Count)?.times ?: 10).toString()) + } + var showUntilPicker by rememberSaveable { mutableStateOf(false) } + + val locale = currentLocale() + val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 } + val count = countText.toIntOrNull()?.takeIf { it in 1..999 } + val customEnd: RecurrenceEnd? = when (endMode) { + RecurrenceEndMode.Never -> RecurrenceEnd.Never + RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) } + RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) } + } + val customResult: String? = if (interval != null && customEnd != null) { + SimpleRecurrence( + freq = freq, + interval = interval, + end = customEnd, + byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(), + ).toRRule() + } else { + null + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.event_detail_recurrence)) }, + text = { + if (!customMode) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OptionCard( + label = stringResource(R.string.event_edit_recurrence_none), + onClick = { onSelect(null) }, + selected = current == null, + ) + RecurrenceFreq.entries.forEach { entry -> + OptionCard( + label = stringResource(recurrencePresetLabel(entry)), + onClick = { onSelect(SimpleRecurrence(entry).toRRule()) }, + selected = isPlainPreset && parsed?.freq == entry, + ) + } + OptionCard( + label = stringResource(R.string.event_edit_recurrence_custom), + onClick = { customMode = true }, + selected = current != null && !isPlainPreset, + labelColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.event_edit_recurrence_every), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(12.dp)) + DialogAmountField( + value = intervalText, + onValueChange = { intervalText = it }, + placeholder = "1", + ) + Spacer(Modifier.width(12.dp)) + DialogUnitDropdown( + label = stringResource(recurrenceUnitLabel(freq)), + entries = RecurrenceFreq.entries.map { + stringResource(recurrenceUnitLabel(it)) + }, + onPick = { freq = RecurrenceFreq.entries[it] }, + ) + } + if (freq == RecurrenceFreq.Weekly) { + Spacer(Modifier.height(4.dp)) + WeekdayToggleRow( + selected = daysMask.toDaySet(), + onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, + locale = locale, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.event_edit_recurrence_ends), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OptionCard( + label = stringResource(R.string.event_edit_recurrence_end_never), + onClick = { endMode = RecurrenceEndMode.Never }, + selected = endMode == RecurrenceEndMode.Never, + ) + OptionCard( + label = stringResource(R.string.event_edit_recurrence_end_until), + onClick = { + endMode = RecurrenceEndMode.Until + showUntilPicker = true + }, + supportingText = untilDate?.let { + remember(it, locale) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale).format(it.toJavaLocalDate()) + } + }, + selected = endMode == RecurrenceEndMode.Until, + ) + OptionCard( + label = stringResource(R.string.event_edit_recurrence_end_count), + onClick = { endMode = RecurrenceEndMode.Count }, + selected = endMode == RecurrenceEndMode.Count, + ) + if (endMode == RecurrenceEndMode.Count) { + Row(verticalAlignment = Alignment.CenterVertically) { + DialogAmountField( + value = countText, + onValueChange = { countText = it }, + placeholder = "10", + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.event_edit_recurrence_times), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + } + }, + confirmButton = { + // The preset list applies on tap; only the custom step needs OK. + if (customMode) { + TextButton( + enabled = customResult != null, + onClick = { customResult?.let(onSelect) }, + ) { Text(stringResource(R.string.dialog_ok)) } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) + + if (showUntilPicker) { + DatePickerAlert( + initial = untilDate ?: LocalDate.fromEpochDays( + (Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(), + ), + onConfirm = { + untilIso = it.toString() + showUntilPicker = false + }, + onDismiss = { showUntilPicker = false }, + ) + } +} + +/** + * One tappable circle per weekday (locale week order), multi-select — the + * BYDAY picks of a weekly rule. Deselecting every day is allowed; the rule + * then falls back to the event's start weekday (provider behaviour). + */ +@Composable +private fun WeekdayToggleRow( + selected: Set, + onToggle: (DayOfWeek) -> Unit, + locale: Locale, +) { + val days = remember(locale) { + val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() + (0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) } + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + days.forEach { day -> + val isSelected = day in selected + Surface( + onClick = { onToggle(day) }, + shape = CircleShape, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + contentColor = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.size(36.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text( + text = day.toJavaDayOfWeek().getDisplayName(JavaTextStyle.NARROW, locale), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } +} + +/** Weekday sets travel through rememberSaveable as an ISO-day bitmask. */ +private fun DayOfWeek.toMaskBit(): Int = 1 shl (isoDayNumber - 1) + +private fun Set.toMask(): Int = fold(0) { acc, day -> acc or day.toMaskBit() } + +private fun Int.toDaySet(): Set = + DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet() + +/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */ +@Composable +private fun DialogAmountField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, +) { + // surfaceContainerHighest — the dialog itself sits on + // surfaceContainerHigh, so anything lower vanishes. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp), + ) { + InlineField( + value = value, + onValueChange = { text -> + if (text.length <= 3 && text.all(Char::isDigit)) { + onValueChange(text) + } + }, + placeholder = placeholder, + textStyle = MaterialTheme.typography.titleMedium, + keyboardType = KeyboardType.Number, + modifier = Modifier + .width(72.dp) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) + } +} + +/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */ +@Composable +private fun DialogUnitDropdown( + label: String, + entries: List, + onPick: (Int) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp), + onClick = { open = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + start = 14.dp, + end = 8.dp, + top = 12.dp, + bottom = 12.dp, + ), + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + entries.forEachIndexed { index, entry -> + DropdownMenuItem( + text = { Text(entry) }, + onClick = { + onPick(index) + open = false + }, + ) + } + } + } +} + +private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) { + RecurrenceFreq.Daily -> R.string.recurrence_daily + RecurrenceFreq.Weekly -> R.string.recurrence_weekly + RecurrenceFreq.Monthly -> R.string.recurrence_monthly + RecurrenceFreq.Yearly -> R.string.recurrence_yearly +} + +private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { + RecurrenceFreq.Daily -> R.string.recurrence_unit_days + RecurrenceFreq.Weekly -> R.string.recurrence_unit_weeks + RecurrenceFreq.Monthly -> R.string.recurrence_unit_months + RecurrenceFreq.Yearly -> R.string.recurrence_unit_years +} + /** One chosen reminder: humanised lead time, remove pinned to the right edge. */ @Composable private fun ReminderRow(label: String, onRemove: () -> Unit) { @@ -793,6 +1226,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description EventFormField.Reminders -> R.string.event_detail_reminders + EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility } @@ -801,6 +1235,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Location -> Icons.Default.Place EventFormField.Description -> Icons.AutoMirrored.Filled.Notes EventFormField.Reminders -> Icons.Default.Notifications + EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Visibility -> Icons.Default.Lock } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 914abb3..7111281 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -19,13 +19,26 @@ data class EventEditUiState( val saveState: SaveUiState, /** Optional sections currently rendered (settings defaults ∪ revealed). */ val visibleFields: Set = emptySet(), - /** True while at least one optional section hides behind "more fields". */ - val hasHiddenFields: Boolean = false, + /** + * Optional sections behind "more fields". Sections the current mode can't + * offer at all (recurrence while editing a single occurrence) appear in + * neither list. + */ + val hiddenFields: List = emptyList(), + /** True while editing an existing event (the calendar is then fixed). */ + val isEditing: Boolean = false, + /** + * True while an edit changed the recurrence rule — the save-scope dialog + * then drops "only this event" (an exception row can't carry a rule). + */ + val recurrenceChanged: Boolean = false, ) /** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */ sealed interface SaveUiState { data object Idle : SaveUiState + /** A dirty recurring event waits for the user to pick the write scope. */ + data object AwaitingScope : SaveUiState data object Saving : SaveUiState data object Saved : SaveUiState /** WRITE_CALENDAR was revoked between the tap and the provider call. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index e27b37a..875b658 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -12,9 +12,13 @@ import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.problems +import de.jeanlucmakiola.calendula.domain.toEditForm import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -54,13 +58,32 @@ class EventEditViewModel @Inject constructor( // form isn't already shouting errors. private val _showProblems = MutableStateFlow(false) // Fields added through the "more fields" picker; folds back on reset(). + // openForEdit seeds it with the sections that already hold values. private val _revealed = MutableStateFlow>(emptySet()) + // Set while the form edits an existing event instead of composing a new one. + private val _editTarget = MutableStateFlow(null) + private val _loadFailed = MutableStateFlow(false) + + /** True when the event to edit couldn't be loaded; the screen closes itself. */ + val loadFailed: StateFlow = _loadFailed.asStateFlow() + + /** + * The event being edited plus the form exactly as it was prefilled. + * For recurring events the write scope is chosen at save time; the + * tapped occurrence's [beginMillis] anchors occurrence-level writes. + */ + private data class EditTarget( + val eventId: Long, + val original: EventForm, + val beginMillis: Long, + ) private data class LocalInputs( val form: EventForm?, val saveState: SaveUiState, val showProblems: Boolean, val revealed: Set, + val editTarget: EditTarget?, ) private data class ExternalInputs( @@ -70,7 +93,7 @@ class EventEditViewModel @Inject constructor( ) val state: StateFlow = combine( - combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs), + combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs), combine( repository.calendars() .map { calendars -> calendars.filter { it.canModifyContents } } @@ -92,7 +115,12 @@ class EventEditViewModel @Inject constructor( problems = if (local.showProblems) resolved.problems() else emptySet(), saveState = local.saveState, visibleFields = visibleFields, - hasHiddenFields = visibleFields.size < EventFormField.entries.size, + hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), + isEditing = local.editTarget != null, + // A modified-occurrence exception can't carry its own rule, so + // the scope dialog drops "only this event" after a rule change. + recurrenceChanged = local.editTarget != null && + resolved.rrule != local.editTarget.original.rrule, ) } .flowOn(io) @@ -123,12 +151,38 @@ class EventEditViewModel @Inject constructor( _form.value = EventForm(calendarId = null, start = start, end = end) } - /** Forget the open form; the next [openNew] starts clean. */ + /** + * Load an existing event into the form. [beginMillis]/[endMillis] are the + * tapped occurrence's own times, like on the detail screen. No-op while a + * form is open, so user edits survive configuration changes. + */ + fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) { + if (_form.value != null || _editTarget.value != null) return + viewModelScope.launch { + val detail = try { + repository.eventDetail(eventId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _loadFailed.value = true + return@launch + } + val original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault()) + _editTarget.value = EditTarget(eventId, original, beginMillis) + // Sections holding data must show even when not in the defaults. + _revealed.value = original.populatedFields() + _form.value = original + } + } + + /** Forget the open form; the next [openNew]/[openForEdit] starts clean. */ fun reset() { _form.value = null _saveState.value = SaveUiState.Idle _showProblems.value = false _revealed.value = emptySet() + _editTarget.value = null + _loadFailed.value = false } /** Unfold one optional field, picked in the "more fields" dialog. */ @@ -144,6 +198,9 @@ class EventEditViewModel @Inject constructor( fun setAvailability(value: Availability) = update { it.copy(availability = value) } fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) } + /** Bare RRULE value from the recurrence picker; null = does not repeat. */ + fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) } + fun addReminder(minutes: Int) = update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) } @@ -158,7 +215,12 @@ class EventEditViewModel @Inject constructor( fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) } fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) } - /** Validate and write. Terminal results land in [saveState]. */ + /** + * Validate and write. Saving a dirty recurring event pauses in + * [SaveUiState.AwaitingScope] until the screen answers via + * [saveWithScope]; everything else writes directly. Terminal results + * land in [saveState]. + */ fun save() { val current = state.value ?: return if (current.saveState == SaveUiState.Saving) return @@ -167,11 +229,49 @@ class EventEditViewModel @Inject constructor( _showProblems.value = true return } + val target = _editTarget.value + if (target != null && form == target.original) { + // A pristine form saves as a no-op instead of a write. + _saveState.value = SaveUiState.Saved + return + } + if (target != null && target.original.rrule != null) { + _saveState.value = SaveUiState.AwaitingScope + return + } + performSave(form, RecurringWriteScope.AllEvents) + } + + /** Finish a save parked in [SaveUiState.AwaitingScope]. */ + fun saveWithScope(scope: RecurringWriteScope) { + val current = state.value ?: return + if (current.saveState != SaveUiState.AwaitingScope) return + performSave(current.form, scope) + } + + private fun performSave(form: EventForm, scope: RecurringWriteScope) { + val target = _editTarget.value viewModelScope.launch { _saveState.value = SaveUiState.Saving _saveState.value = try { - repository.createEvent(form) - prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) + if (target == null) { + repository.createEvent(form) + prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) + } else { + when (scope) { + RecurringWriteScope.ThisEvent -> + repository.updateOccurrence(target.eventId, target.beginMillis, form) + RecurringWriteScope.ThisAndFollowing -> + repository.updateEventFromOccurrence( + eventId = target.eventId, + beginMillis = target.beginMillis, + original = target.original, + updated = form, + ) + RecurringWriteScope.AllEvents -> + repository.updateEvent(target.eventId, target.original, form) + } + } SaveUiState.Saved } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index dd43224..20d8a4f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -340,6 +340,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description EventFormField.Reminders -> R.string.event_detail_reminders + EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f6f1ec4..599da52 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -45,12 +45,15 @@ Zurück + Bearbeiten Löschen Termin löschen? Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt. Wiederkehrenden Termin löschen Nur dieser Termin + Dieser und alle folgenden Termine Alle Termine der Serie + Wiederkehrenden Termin bearbeiten Termin konnte nicht gelöscht werden Calendula braucht Schreibzugriff, um Termine zu löschen Abbrechen @@ -78,6 +81,21 @@ Wochen Verfügbarkeit Sichtbarkeit + + + Wiederholt sich nicht + Benutzerdefiniert + Alle + Tage + Wochen + Monate + Jahre + Endet + Nie + An einem Datum + Nach einer Anzahl + Mal + Wiederholung endet vor dem Beginn Beschäftigt Standard Öffentlich diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b1f6e3f..396f8ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,12 +46,15 @@ Back + Edit Delete Delete event? The event is removed from your calendar and from every device it syncs to. Delete recurring event Only this event + This and all following events All events in the series + Edit recurring event Couldn\'t delete the event Calendula needs write access to delete events Cancel @@ -79,6 +82,21 @@ weeks Availability Visibility + + + Does not repeat + Custom + Every + days + weeks + months + years + Ends + Never + On a date + After a number of times + times + Repeats end before the event starts Busy Default Public 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 4a6d903..22aaa70 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 @@ -198,6 +198,43 @@ class CalendarRepositoryImplTest { } } + @Test + fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource() + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined) + val original = EventForm( + calendarId = 1L, + title = "Stand-up", + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)), + ) + val updated = original.copy(title = "Daily") + + repo.updateEvent(eventId = 42L, original = original, updated = updated) + + assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated)) + } + + @Test + fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { + writeError = WriteFailedException("update event id=42") + } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined) + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)), + ) + + try { + repo.updateEvent(eventId = 42L, original = form, updated = form.copy(title = "X")) + error("Expected WriteFailedException") + } catch (expected: WriteFailedException) { + assertThat(expected.message).contains("42") + } + } + @Test fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest { val fake = FakeCalendarDataSource() @@ -220,6 +257,61 @@ class CalendarRepositoryImplTest { assertThat(fake.deletedEventIds).isEmpty() } + @Test + fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource() + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined) + + repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L) + + assertThat(fake.deletedFromOccurrences).containsExactly(42L to 1_000L) + assertThat(fake.deletedEventIds).isEmpty() + assertThat(fake.deletedOccurrences).isEmpty() + } + + @Test + fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { nextInsertId = 88L } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined) + val form = EventForm( + calendarId = 1L, + title = "Moved", + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)), + ) + + val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form) + + assertThat(id).isEqualTo(88L) + assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form)) + assertThat(fake.updatedEvents).isEmpty() + } + + @Test + fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { nextInsertId = 99L } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined) + val original = EventForm( + calendarId = 1L, + title = "Weekly", + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)), + rrule = "FREQ=WEEKLY", + ) + val updated = original.copy(title = "Weekly, renamed") + + val id = repo.updateEventFromOccurrence( + eventId = 42L, + beginMillis = 1_000L, + original = original, + updated = updated, + ) + + assertThat(id).isEqualTo(99L) + assertThat(fake.updatedFromOccurrences).containsExactly(Triple(42L, 1_000L, updated)) + assertThat(fake.updatedEvents).isEmpty() + } + @Test fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest { val fake = FakeCalendarDataSource().apply { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt index b22b6cb..ab96723 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -88,6 +88,12 @@ class EventDetailMapperTest { assertThat(detail.attendees).isEmpty() } + @Test + fun `missing title stays raw so the edit form does not inherit a placeholder`() { + assertThat(detailReader(title = null).toDetail()!!.instance.title).isEmpty() + assertThat(detailReader(title = "").toDetail()!!.instance.title).isEmpty() + } + @Test fun `event color falls back to calendar color when null`() { val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt()) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 096a874..5fd2052 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -71,4 +71,151 @@ class EventWriteMapperTest { // 11th, 12th, 13th inclusive = 3 days. assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L) } + + @Test + fun `truncation cutoff is the end of the previous local day`() { + // June 9 2026 15:30Z == 17:30 in Berlin. The cutoff must be + // June 8 23:59:59 Berlin == June 8 21:59:59Z. + assertThat(previousLocalDayEndUtcMillis(1_781_019_000_000L, berlin)) + .isEqualTo(1_780_955_999_000L) + // All-day series live in UTC: the cutoff for a June 9 UTC-midnight + // occurrence is June 8 23:59:59Z. + assertThat(previousLocalDayEndUtcMillis(1_780_963_200_000L, ZoneId.of("UTC"))) + .isEqualTo(1_780_963_199_000L) + } + + @Test + fun `duration renders seconds for timed and days for all-day events`() { + assertThat(form().toWriteTimes(berlin).toRfc2445Duration(isAllDay = false)) + .isEqualTo("P5400S") + assertThat(form(isAllDay = true).toWriteTimes(berlin).toRfc2445Duration(isAllDay = true)) + .isEqualTo("P1D") + } + + // --- buildEventUpdateValues (dirty-checked partial update) --- + + private val seriesStart = 1_700_000_000_000L + + private fun update(original: EventForm, updated: EventForm): Map = + buildEventUpdateValues(original, updated, seriesStart, berlin) + + @Test + fun `pristine form produces no values`() { + val original = form() + assertThat(update(original, original.copy())).isEmpty() + } + + @Test + fun `text-only edit writes just the changed columns`() { + val original = form() + val values = update(original, original.copy(title = "New", description = "Body")) + assertThat(values).containsExactly( + CalendarContract.Events.TITLE, "New", + CalendarContract.Events.DESCRIPTION, "Body", + ) + } + + @Test + fun `clearing location writes an explicit null`() { + val original = form().copy(location = "Berlin") + val values = update(original, original.copy(location = " ")) + assertThat(values).containsExactly(CalendarContract.Events.EVENT_LOCATION, null) + } + + @Test + fun `time edit on a one-off event writes absolute times and clears recurrence columns`() { + val original = form() + val updated = original.copy( + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)), + ) + val values = update(original, updated) + // 2026-06-11 11:00 CEST == 09:00Z. + assertThat(values[CalendarContract.Events.DTSTART]) + .isEqualTo(1_781_164_800_000L + 3_600_000L) + assertThat(values[CalendarContract.Events.DTEND]) + .isEqualTo(1_781_164_800_000L + 2L * 3_600_000L) + assertThat(values).containsEntry(CalendarContract.Events.RRULE, null) + assertThat(values).containsEntry(CalendarContract.Events.DURATION, null) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(0) + } + + @Test + fun `time edit on a recurring event moves the series start by the same delta`() { + val original = form().copy(rrule = "FREQ=WEEKLY") + val updated = original.copy( + // Pushed one hour later than the displayed occurrence. + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)), + ) + val values = update(original, updated) + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L) + assertThat(values).containsEntry(CalendarContract.Events.DTEND, null) + assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY") + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + } + + @Test + fun `adding a recurrence keeps the times and writes rule plus duration`() { + val original = form() + val values = update(original, original.copy(rrule = "FREQ=DAILY;COUNT=5")) + // The event was one-off, so the row's DTSTART is the occurrence start + // and a zero delta keeps it in place. + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart) + assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5") + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + assertThat(values).containsEntry(CalendarContract.Events.DTEND, null) + } + + @Test + fun `removing the recurrence writes absolute occurrence times and clears the rule`() { + val original = form().copy(rrule = "FREQ=WEEKLY") + val values = update(original, original.copy(rrule = null)) + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + assertThat(values[CalendarContract.Events.DTEND]) + .isEqualTo(1_781_164_800_000L + 5_400_000L) + assertThat(values).containsEntry(CalendarContract.Events.RRULE, null) + assertThat(values).containsEntry(CalendarContract.Events.DURATION, null) + } + + @Test + fun `reminder-only changes touch no event columns`() { + val original = form() + assertThat(update(original, original.copy(reminders = listOf(10)))).isEmpty() + } + + // --- buildOccurrenceExceptionValues ("edit only this event") --- + + @Test + fun `occurrence exception carries absolute times and the original instance`() { + val values = buildOccurrenceExceptionValues( + form = form().copy(title = "Moved", location = "Berlin"), + originalInstanceMillis = 1_700_000_000_000L, + zone = berlin, + ) + assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME]) + .isEqualTo(1_700_000_000_000L) + assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") + assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + assertThat(values[CalendarContract.Events.DTEND]) + .isEqualTo(1_781_164_800_000L + 5_400_000L) + // A single occurrence never carries its own rule. + assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) + assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) + } + + @Test + fun `occurrence exception clears empty optionals explicitly`() { + // The provider clones the parent row, so a blank field must be an + // explicit NULL or the parent's value would survive. + val values = buildOccurrenceExceptionValues( + form = form(), + originalInstanceMillis = 0L, + zone = berlin, + ) + assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null) + assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null) + } } 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 fc30ccb..b465353 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 @@ -20,8 +20,12 @@ internal class FakeCalendarDataSource : CalendarDataSource { var nextInsertId: Long = 100L val insertedForms = mutableListOf() + val updatedEvents = mutableListOf>() + val updatedOccurrences = mutableListOf>() + val updatedFromOccurrences = mutableListOf>() val deletedEventIds = mutableListOf() val deletedOccurrences = mutableListOf>() + val deletedFromOccurrences = mutableListOf>() private val listeners = mutableListOf<() -> Unit>() @@ -36,6 +40,33 @@ internal class FakeCalendarDataSource : CalendarDataSource { return nextInsertId } + override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) { + writeError?.let { throw it } + updatedEvents += Triple(eventId, original, updated) + } + + override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long { + writeError?.let { throw it } + updatedOccurrences += Triple(eventId, beginMillis, form) + return nextInsertId + } + + override fun updateEventFromOccurrence( + eventId: Long, + beginMillis: Long, + original: EventForm, + updated: EventForm, + ): Long { + writeError?.let { throw it } + updatedFromOccurrences += Triple(eventId, beginMillis, updated) + return nextInsertId + } + + override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) { + writeError?.let { throw it } + deletedFromOccurrences += eventId to beginMillis + } + override fun deleteEvent(eventId: Long) { writeError?.let { throw it } deletedEventIds += eventId diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index 5e3705f..f845f45 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -4,7 +4,9 @@ import com.google.common.truth.Truth.assertThat import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone import org.junit.jupiter.api.Test +import kotlin.time.Instant class EventFormTest { @@ -69,4 +71,132 @@ class EventFormTest { EventFormProblem.EndBeforeStart, ) } + + @Test + fun `recurrence until before the first day is a problem`() { + // Days before the start, so it parses to an earlier date in any zone. + val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z") + assertThat(bad.problems()) + .containsExactly(EventFormProblem.RecurrenceEndsBeforeStart) + } + + @Test + fun `recurrence until on or after the first day is fine`() { + // Date-only UNTIL parses zone-independently. + assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty() + assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty() + } + + @Test + fun `complex rrules are not validated against the start`() { + // The picker can't have produced this ("second Monday" ordinal BYDAY); + // it is preserved verbatim and never flagged. + assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty() + } + + @Test + fun `weekly byday rules are validated against the start`() { + val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101") + assertThat(bad.problems()) + .containsExactly(EventFormProblem.RecurrenceEndsBeforeStart) + } + + private val berlin = TimeZone.of("Europe/Berlin") + + private fun detail( + isAllDay: Boolean = false, + title: String = "Stand-up", + location: String? = "Berlin", + description: String? = "Body", + rrule: String? = null, + reminders: List = emptyList(), + availability: Availability = Availability.Busy, + accessLevel: AccessLevel = AccessLevel.Default, + ): EventDetail = EventDetail( + instance = EventInstance( + instanceId = 1L, + eventId = 1L, + calendarId = 7L, + title = title, + start = Instant.fromEpochMilliseconds(0L), + end = Instant.fromEpochMilliseconds(0L), + isAllDay = isAllDay, + color = 0, + location = location, + ), + description = description, + organizer = null, + attendees = emptyList(), + rrule = rrule, + reminders = reminders, + availability = availability, + accessLevel = accessLevel, + ) + + @Test + fun `toEditForm prefills a timed event from the occurrence times`() { + // 2026-06-11 is CEST (UTC+2): 08:00Z == 10:00 local. + val prefilled = detail().toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 90L * 60L * 1000L, + zone = berlin, + ) + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) + assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30))) + assertThat(prefilled.isAllDay).isFalse() + assertThat(prefilled.calendarId).isEqualTo(7L) + assertThat(prefilled.title).isEqualTo("Stand-up") + assertThat(prefilled.location).isEqualTo("Berlin") + assertThat(prefilled.description).isEqualTo("Body") + } + + @Test + fun `toEditForm turns the exclusive all-day end into the last covered day`() { + // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. + val prefilled = detail(isAllDay = true).toEditForm( + beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L, + endMillis = LocalDate(2026, 6, 14).toEpochDays() * 86_400_000L, + zone = berlin, + ) + assertThat(prefilled.start.date).isEqualTo(LocalDate(2026, 6, 11)) + assertThat(prefilled.end.date).isEqualTo(LocalDate(2026, 6, 13)) + assertThat(prefilled.isAllDay).isTrue() + } + + @Test + fun `toEditForm sorts and dedupes reminder minutes and strips an RRULE prefix`() { + val prefilled = detail( + rrule = "RRULE:FREQ=WEEKLY", + reminders = listOf( + Reminder(30, ReminderMethod.Email), + Reminder(10, ReminderMethod.Alert), + Reminder(30, ReminderMethod.Alert), + ), + ).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin) + assertThat(prefilled.reminders).containsExactly(10, 30).inOrder() + assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY") + } + + @Test + fun `populatedFields reports exactly the sections holding values`() { + val empty = form().copy(location = "", description = "") + assertThat(empty.populatedFields()).isEmpty() + + val full = form().copy( + location = "Berlin", + description = "Body", + reminders = listOf(10), + rrule = "FREQ=DAILY", + availability = Availability.Free, + accessLevel = AccessLevel.Private, + ) + assertThat(full.populatedFields()).containsExactly( + EventFormField.Location, + EventFormField.Description, + EventFormField.Reminders, + EventFormField.Recurrence, + EventFormField.Availability, + EventFormField.Visibility, + ) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceTest.kt new file mode 100644 index 0000000..137ab0e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceTest.kt @@ -0,0 +1,164 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test + +class RecurrenceTest { + + private val utc = TimeZone.UTC + private val berlin = TimeZone.of("Europe/Berlin") + + @Test + fun `plain frequency parses with defaults`() { + assertThat(parseSimpleRecurrence("FREQ=WEEKLY")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly)) + assertThat(parseSimpleRecurrence("FREQ=DAILY")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily)) + } + + @Test + fun `leading RRULE prefix and WKST are tolerated`() { + assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly)) + } + + @Test + fun `interval parses`() { + assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2)) + } + + @Test + fun `until parses date-only and UTC datetime forms`() { + val expected = SimpleRecurrence( + RecurrenceFreq.Daily, + end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)), + ) + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected) + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc)) + .isEqualTo(expected) + } + + @Test + fun `until datetime converts from UTC into the given zone before taking the date`() { + // 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there. + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin)) + .isEqualTo( + SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))), + ) + } + + @Test + fun `count parses`() { + assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5))) + } + + @Test + fun `weekly byday parses as weekday picks`() { + assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR")) + .isEqualTo( + SimpleRecurrence( + RecurrenceFreq.Weekly, + byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY), + ), + ) + } + + @Test + fun `rules beyond the simple shape are rejected`() { + // Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules. + assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull() + assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull() + assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull() + assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull() + assertThat(parseSimpleRecurrence("")).isNull() + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull() + assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull() + assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull() + } + + @Test + fun `toRRule renders the minimal form`() { + assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY") + assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule()) + .isEqualTo("FREQ=DAILY;INTERVAL=3") + assertThat( + SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(), + ).isEqualTo("FREQ=MONTHLY;COUNT=12") + } + + @Test + fun `toRRule renders weekdays in ISO order regardless of set order`() { + val rule = SimpleRecurrence( + RecurrenceFreq.Weekly, + byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY), + ).toRRule() + assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR") + } + + @Test + fun `toRRule ignores weekday picks on non-weekly frequencies`() { + val rule = SimpleRecurrence( + RecurrenceFreq.Monthly, + byDays = setOf(DayOfWeek.MONDAY), + ).toRRule() + assertThat(rule).isEqualTo("FREQ=MONTHLY") + } + + @Test + fun `toRRule writes until as the end of the chosen day in the given zone`() { + val rule = SimpleRecurrence( + RecurrenceFreq.Weekly, + interval = 2, + end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)), + ) + assertThat(rule.toRRule(utc)) + .isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z") + // 23:59:59 Berlin (CEST, +2) == 21:59:59Z. + assertThat(rule.toRRule(berlin)) + .isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z") + } + + // 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence. + private val cutoffMillis = 1_781_913_540_000L + + @Test + fun `truncation replaces count and keeps every other part`() { + assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis)) + .isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z") + } + + @Test + fun `truncation replaces an existing until`() { + assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis)) + .isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z") + } + + @Test + fun `truncation works on rules the simple picker cannot express`() { + assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis)) + .isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z") + } + + @Test + fun `parse and render round-trip`() { + val rules = listOf( + "FREQ=DAILY", + "FREQ=WEEKLY;INTERVAL=2", + "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z", + "FREQ=MONTHLY;COUNT=6", + "FREQ=YEARLY;UNTIL=20301231T235959Z", + ) + rules.forEach { rule -> + assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule) + } + // Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET. + val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z" + assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin)) + .isEqualTo(berlinRule) + } +} diff --git a/docs/superpowers/plans/2026-06-11-03-write-support.md b/docs/superpowers/plans/2026-06-11-03-write-support.md index 71a099d..0d2940e 100644 --- a/docs/superpowers/plans/2026-06-11-03-write-support.md +++ b/docs/superpowers/plans/2026-06-11-03-write-support.md @@ -47,7 +47,7 @@ Domain bleibt pure Kotlin. |---|---|---| | v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) | | v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) | -| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | offen | +| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | implementiert (Release wartet auf On-Device-Review) | | v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen | ## v1.1 — Write-Fundament + Delete @@ -95,11 +95,90 @@ Domain bleibt pure Kotlin. - [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar) normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND -## v1.3 — Edit (Skizze) +## v1.3 — Edit -- Formular lädt `EventDetail`, Dirty-Check, `update` auf Events-Row -- Reminder hinzufügen/entfernen (`Reminders`-Insert/Delete) -- Einfacher Recurrence-Picker (FREQ + INTERVAL + UNTIL/COUNT) +**Domain:** +- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln + (ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten, + solange der Picker sie nicht ersetzt +- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches + BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit + `parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet) +- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day- + Rückrechnung (exklusives DTEND → letzter abgedeckter Tag) +- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße + null Vorkommen — Event würde unsichtbar) + +**Data layer:** +- [x] `buildEventUpdateValues(original, updated, seriesDtStart, zone)` — + Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit: + einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend → + Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTEND +- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row- + Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode) +- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante) +- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io` +- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr — + der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert) + +**UI:** +- [x] `EventEditViewModel.openForEdit` (lädt Detail, merkt Original für + Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit + Werten werden unabhängig vom Settings-Default eingeblendet +- [x] `EventEditScreen`: `editKey`-Parameter, Kalender im Edit-Modus fixiert, + Repeat-Karte + `RecurrencePickerDialog` (Presets per Tap, Custom-Schritt + mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart + nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl, + OptionCard-Stil) +- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular) +- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller + WRITE-Request wie Delete); Save schließt Formular **und** Detail (die + getappte Occurrence existiert danach evtl. nicht mehr) +- [x] **Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback):** Die + Scope-Frage kommt **beim Speichern** (Google-Modell, Review-Feedback): + ein dirty wiederkehrender Termin parkt in `SaveUiState.AwaitingScope`, + der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden / + Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser" + (eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt + eine Modified-Occurrence-Exception (`CONTENT_EXCEPTION_URI`, alle + Formularwerte, leere Optionals als explizite NULLs weil der Provider + die Serien-Row klont), Reminder werden gegen die tatsächlichen + Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split: + neues Event mit den Formularwerten (insert zuerst — schlägt es fehl, + bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt; + ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes + COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP) +- [x] **Delete dreistufig (Review-Feedback):** "Nur dieser Termin" / + "Dieser und alle folgenden" (RRULE-Truncation via `rruleTruncatedAt`) + / "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie + löschen +- [x] **Split-Duplikat-Bugfix (On-Device-Review):** Nach dem Serien-Split + blieb die getappte Occurrence doppelt sichtbar. Root cause (per + adb-Probe verifiziert): der Provider regeneriert die Instances eines + Events nur aus den **Values des Updates selbst** — ein RRULE-only- + Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART) + erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken + deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/ + EVENT_TIMEZONE) zusammen (`truncateSeries`), wie AOSPs + EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff = + Ende des Vortags in der Event-Zeitzone (`previousLocalDayEndUtcMillis`) + statt Occurrence−1s, und der Recurrence-Picker rendert UNTIL als + lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z` + (sonst kann bei UTC+x ein Extra-Tag hineinrutschen) +- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern +- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default) +- [x] Strings DE+EN + +**Tests:** +- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln) +- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung +- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only, + Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only) +- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade +- [x] `EventDetailMapperTest`: roher Titel + +Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim +Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps). ## v2.0 — Abschluss (Skizze)