From 4503847c0dc26db100880deb46785b03f966c556 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 16:11:32 +0200 Subject: [PATCH] feat(edit): move an event to another calendar (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar row in the editor is now tappable when editing an existing event: picking a different calendar moves the event there on save, rather than forcing a delete-and-recreate. CALENDAR_ID is sync-adapter-owned and can't be updated in place, so the move is copy+delete: the master row is re-inserted on the target calendar (preserving UID_2445 so backup dedup and sync identity survive), its reminders and editable guests are copied, and — for a recurring series — every exception is replayed against the new master (modified occurrences via CONTENT_EXCEPTION_URI, cancellations as STATUS_CANCELED). The user's field edits are then applied with the normal series update. Everything on the new side is built before the source is deleted (post-before-delete), with a rollback of the copy on any failure, so a move is all-or-nothing. A calendar change forces whole-series scope, so it skips the recurring scope dialog. Managed special-dates calendars stay locked. Colour is not carried across (a raw/keyed colour may be invalid on the target account), matching the existing calendar-switch behaviour. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++ .../data/calendar/CalendarDataSource.kt | 205 ++++++++++++++++++ .../data/calendar/CalendarRepository.kt | 14 ++ .../data/calendar/CalendarRepositoryImpl.kt | 11 + .../data/calendar/EventWriteMapper.kt | 130 ++++++++++- .../calendula/data/calendar/Projections.kt | 80 +++++++ .../calendula/ui/edit/EventEditScreen.kt | 9 +- .../calendula/ui/edit/EventEditViewModel.kt | 18 +- .../calendar/CalendarRepositoryImplTest.kt | 40 ++++ .../data/calendar/EventWriteMapperTest.kt | 110 ++++++++++ .../data/calendar/FakeCalendarDataSource.kt | 20 ++ .../ui/edit/EventEditViewModelTest.kt | 158 ++++++++++++++ 12 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d8da9d1..8db11ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Move an event to another calendar. When editing an existing event, the + calendar row is now tappable — pick a different calendar and saving moves the + event across, instead of having to delete it and recreate it elsewhere. + Recurring series move as a whole, keeping their individually-edited and + cancelled occurrences, and any reminders and guests come along too. A calendar + can't simply be reassigned underneath an event, so Calendula recreates it on + the target and removes the original — the same approach other calendar apps + take. Thanks to @prismplex for the suggestion ([#39]). + ## [2.14.0] — 2026-07-06 ### Added @@ -874,3 +886,4 @@ automatically, with zero telemetry and no internet permission. [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37 +[#39]: https://codeberg.org/jlmakiola/calendula/issues/39 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 eeb7821..9dd6112 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 @@ -18,6 +18,8 @@ import android.util.Log import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee +import de.jeanlucmakiola.calendula.domain.AttendeeRelationship +import de.jeanlucmakiola.calendula.domain.AttendeeType import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption @@ -176,6 +178,25 @@ interface CalendarDataSource { allDayReminderTimeMinutes: Int, ) + /** + * Move an event (for recurring events: the whole series, with its modified + * and cancelled occurrences) to [targetCalendarId], returning the new + * `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in + * place, so this is copy+delete: the row is re-inserted on the target (its + * `UID_2445` preserved), its exceptions, reminders and editable guests + * replayed, the [original]→[updated] field edits applied, then the source + * deleted. The source is removed only once the copy fully succeeds; a failure + * before that rolls the new event back, leaving the original untouched. + * [allDayReminderTimeMinutes]: see [insertEvent]. + */ + fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + allDayReminderTimeMinutes: Int, + ): Long + /** * Change a single occurrence of a recurring event by inserting a * modified-occurrence exception at [beginMillis] (the occurrence's @@ -876,6 +897,190 @@ class AndroidCalendarDataSource @Inject constructor( } } + override fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + allDayReminderTimeMinutes: Int, + ): Long { + // CALENDAR_ID can't be updated in place, so re-create the event on the + // target calendar and delete the source. Everything that identifies or + // hangs off the event is copied first; the source row is removed only + // once the copy (including its exceptions) is complete — a failure + // anywhere before that rolls the new event back, so the move is + // all-or-nothing and never leaves a half-copied duplicate. + val master = queryMoveMaster(eventId) + ?: throw WriteFailedException("read event to move id=$eventId") + // Keep the source UID so an .ics backup dedups and a sync adapter can + // recognise the moved event; mint one only if the source carried none. + val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula" + val newEventId = resolver.insert( + CalendarContract.Events.CONTENT_URI, + buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(), + )?.let(ContentUris::parseId) + ?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId") + try { + copyReminderRows(fromEventId = eventId, toEventId = newEventId) + insertAttendees(newEventId, editableAttendees(eventId)) + copyExceptions(fromEventId = eventId, toEventId = newEventId) + // Apply the user's field edits exactly as a same-calendar "all + // events" save would: the moved master carries the source's values, + // so the dirty diff against [original] writes only what changed + // (including a time or rrule edit made in the same save). + updateEvent(newEventId, original, updated, allDayReminderTimeMinutes) + } catch (t: Throwable) { + // Undo the partial copy so a failed move leaves nothing behind; the + // source is still intact (it's deleted only past this point). + runCatching { deleteEvent(newEventId) } + throw t + } + deleteEvent(eventId) + return newEventId + } + + /** The master row of the event to move, as a verbatim-insert snapshot. */ + private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + MoveMasterProjection.COLUMNS, + null, null, null, + )?.use { c -> + if (!c.moveToFirst()) return@use null + val r = CursorColumnReader(c) + MoveMaster( + uid = r.getString(MoveMasterProjection.IDX_UID), + event = MasterEventSnapshot( + title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(), + isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0, + dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART), + dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND) + .takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) }, + duration = r.getString(MoveMasterProjection.IDX_DURATION), + rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }, + rdate = r.getString(MoveMasterProjection.IDX_RDATE), + exdate = r.getString(MoveMasterProjection.IDX_EXDATE), + timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE), + availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY), + accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL), + status = r.getInt(MoveMasterProjection.IDX_STATUS) + .takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) }, + location = r.getString(MoveMasterProjection.IDX_LOCATION), + description = r.getString(MoveMasterProjection.IDX_DESCRIPTION), + ), + ) + } + + private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot) + + /** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */ + private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query( + CalendarContract.Reminders.CONTENT_URI, + arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD), + CalendarContract.Reminders.EVENT_ID + " = ?", + arrayOf(fromEventId.toString()), + null, + )?.use { c -> + while (c.moveToNext()) { + val values = ContentValues().apply { + put(CalendarContract.Reminders.EVENT_ID, toEventId) + put(CalendarContract.Reminders.MINUTES, c.getInt(0)) + put(CalendarContract.Reminders.METHOD, c.getInt(1)) + } + if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) { + Log.w(TAG, "Failed to copy a reminder to moved event $toEventId") + } + } + } ?: Unit + + /** + * The event's editable guests as [EventAttendee]s — the rows the move + * re-creates. Mirrors the edit form's filter: the organizer and resource + * rows (backend-owned, not user-editable) and any without an email are + * dropped; the response status resets to "invited" on re-insert. + */ + private fun editableAttendees(eventId: Long): List = queryAttendees(eventId) + .filter { + it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource + } + .mapNotNull { a -> + a.email?.takeIf { it.isNotBlank() }?.let { email -> + EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional) + } + } + + /** + * Replay every exception of the series [fromEventId] against the moved series + * [toEventId]. The copy preserved the recurrence skeleton, so each occurrence + * time still resolves: a cancelled occurrence is re-hidden with a cancelled + * exception, a modified one is re-inserted with its fields (then its reminders + * reconciled and editable guests copied). No-op for a non-recurring event. + */ + private fun copyExceptions(fromEventId: Long, toEventId: Long) { + queryExceptionRows(fromEventId).forEach { ex -> + if (ex.isCancelled) { + val values = ContentValues().apply { + put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis) + put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) + } + resolver.insert( + ContentUris.withAppendedId( + CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, + ), + values, + ) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId") + } else { + val uri = resolver.insert( + ContentUris.withAppendedId( + CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, + ), + buildCopiedExceptionValues(ex).toContentValues(), + ) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId") + val newExceptionId = ContentUris.parseId(uri) + // The provider may clone the parent's reminders onto the new + // exception; reconcile to the source exception's exact set so + // they neither double nor drop. Same DTSTART → same all-day + // encoding, so the stored offsets match directly. + reconcileReminders( + newExceptionId, + queryReminders(ex.exceptionEventId).map { it.minutes }, + ) + insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId)) + } + } + } + + /** The series' exception rows (modified + cancelled), oldest occurrence first. */ + private fun queryExceptionRows(seriesEventId: Long): List = resolver.query( + CalendarContract.Events.CONTENT_URI, + ExceptionProjection.COLUMNS, + "${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0", + arrayOf(seriesEventId.toString()), + CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC", + )?.use { c -> + c.mapAll { + val r = CursorColumnReader(c) + val status = r.getInt(ExceptionProjection.IDX_STATUS) + .takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) } + ExceptionRowSnapshot( + exceptionEventId = r.getLong(ExceptionProjection.IDX_ID), + originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME), + isCancelled = status == CalendarContract.Events.STATUS_CANCELED, + status = status, + title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(), + isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0, + dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART), + dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND) + .takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) }, + duration = r.getString(ExceptionProjection.IDX_DURATION), + timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE), + availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY), + accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL), + location = r.getString(ExceptionProjection.IDX_LOCATION), + description = r.getString(ExceptionProjection.IDX_DESCRIPTION), + ) + } + } ?: emptyList() + override fun updateOccurrence( eventId: Long, beginMillis: Long, 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 422e361..c006159 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 @@ -61,6 +61,20 @@ interface CalendarRepository { */ suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) + /** + * Move an event (recurring: the whole series, with its exceptions) to + * [targetCalendarId] and apply the [original]→[updated] field edits; returns + * the new event's `Events._ID`. Copy+delete under the hood + * (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in + * place. + */ + suspend fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + ): Long + /** * Change a single occurrence of a recurring event (exception row with the * form's values); returns the exception's `Events._ID`. 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 b7fd51e..f9528d2 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 @@ -157,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.deleteEvent(eventId) } + override suspend fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + ): Long = withContext(io) { + dataSource.moveEvent( + eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(), + ) + } + override suspend fun updateOccurrence( eventId: Long, beginMillis: Long, 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 dc830aa..a8ad518 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 @@ -43,10 +43,19 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa * 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" +internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = + rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay) + +/** + * RFC 2445 duration for a [spanMillis]-long event: whole days for all-day + * events (the provider's convention), seconds otherwise. Shared by the write + * paths that need a DURATION but start from a raw millisecond span (the series + * copy and exception replay of a calendar move) rather than [EventWriteTimes]. + */ +internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) { + "P${spanMillis / MILLIS_PER_DAY}D" } else { - "P${(dtEndMillis - dtStartMillis) / 1_000L}S" + "P${spanMillis / 1_000L}S" } /** @@ -182,6 +191,121 @@ internal fun buildOccurrenceExceptionValues( putAll(eventColorColumns(form.colorKey, form.color)) } +/** + * Raw provider snapshot of a master/one-off Events row, enough to re-insert it + * verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID` + * is sync-adapter-owned and can't be updated in place). Recurring rows carry + * [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis]; + * one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw + * `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target + * account, so the moved copy inherits the target calendar's colour instead. + */ +internal data class MasterEventSnapshot( + val title: String, + val isAllDay: Boolean, + val dtStartMillis: Long, + val dtEndMillis: Long?, + val duration: String?, + val rrule: String?, + val rdate: String?, + val exdate: String?, + val timezone: String?, + val availability: Int, + val accessLevel: Int, + val status: Int?, + val location: String?, + val description: String?, +) + +/** + * Column values re-creating [snapshot] as a fresh Events row on + * [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity + * survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION, + * RDATE/EXDATE) so the series' generated instances — and therefore the + * ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The + * caller layers the user's field edits on top with a normal series update. + */ +internal fun buildMovedMasterValues( + snapshot: MasterEventSnapshot, + targetCalendarId: Long, + uid: String, +): Map = buildMap { + put(CalendarContract.Events.CALENDAR_ID, targetCalendarId) + put(CalendarContract.Events.UID_2445, uid) + put(CalendarContract.Events.TITLE, snapshot.title) + put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis) + put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC") + if (snapshot.rrule != null) { + put(CalendarContract.Events.RRULE, snapshot.rrule) + snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) } + snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) } + put(CalendarContract.Events.DURATION, snapshot.movedDuration()) + } else { + snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) } + } + put(CalendarContract.Events.AVAILABILITY, snapshot.availability) + put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel) + snapshot.status?.let { put(CalendarContract.Events.STATUS, it) } + put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null }) + put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null }) +} + +/** The recurring copy's DURATION: its own if present, else derived from DTEND. */ +private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() } + ?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay) + +/** + * Raw provider snapshot of one exception row of a recurring series (a modified + * or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis] + * ties it to the occurrence it overrides; a [isCancelled] row only needs that. + */ +internal data class ExceptionRowSnapshot( + val exceptionEventId: Long, + val originalInstanceMillis: Long, + val isCancelled: Boolean, + val status: Int?, + val title: String, + val isAllDay: Boolean, + val dtStartMillis: Long, + val dtEndMillis: Long?, + val duration: String?, + val timezone: String?, + val availability: Int, + val accessLevel: Int, + val location: String?, + val description: String?, +) + +/** + * Column values replaying a *modified* occurrence [snapshot] against the moved + * series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the + * length travels as DURATION (the provider rejects DTEND on an exception). A + * cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME + + * STATUS_CANCELED) — this builder is only for the modified case. + */ +internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map = + buildMap { + put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis) + put(CalendarContract.Events.TITLE, snapshot.title) + put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis) + put( + CalendarContract.Events.DURATION, + snapshot.duration?.takeIf { it.isNotBlank() } + ?: rfc2445Duration( + (snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis, + snapshot.isAllDay, + ), + ) + put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC") + put(CalendarContract.Events.AVAILABILITY, snapshot.availability) + put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel) + put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null }) + put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null }) + snapshot.status?.let { put(CalendarContract.Events.STATUS, it) } + } + /** * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A * [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 2066ac7..0ab8f79 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -186,6 +186,86 @@ internal object SearchProjection { const val IDX_RDATE = 11 } +/** + * The master/one-off Events row of an event about to be moved to another + * calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries + * the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy + * generates the same instances, and `UID_2445` so identity survives the move. + */ +internal object MoveMasterProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events.UID_2445, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, + CalendarContract.Events.RRULE, + CalendarContract.Events.RDATE, + CalendarContract.Events.EXDATE, + CalendarContract.Events.EVENT_TIMEZONE, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.AVAILABILITY, + CalendarContract.Events.ACCESS_LEVEL, + CalendarContract.Events.STATUS, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.DESCRIPTION, + ) + + const val IDX_UID = 0 + const val IDX_TITLE = 1 + const val IDX_DTSTART = 2 + const val IDX_DTEND = 3 + const val IDX_DURATION = 4 + const val IDX_RRULE = 5 + const val IDX_RDATE = 6 + const val IDX_EXDATE = 7 + const val IDX_EVENT_TIMEZONE = 8 + const val IDX_ALL_DAY = 9 + const val IDX_AVAILABILITY = 10 + const val IDX_ACCESS_LEVEL = 11 + const val IDX_STATUS = 12 + const val IDX_LOCATION = 13 + const val IDX_DESCRIPTION = 14 +} + +/** + * The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to + * replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified + * occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters + * `DELETED = 0` so provider tombstones aren't replayed. + */ +internal object ExceptionProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.ORIGINAL_INSTANCE_TIME, + CalendarContract.Events.STATUS, + CalendarContract.Events.TITLE, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, + CalendarContract.Events.EVENT_TIMEZONE, + CalendarContract.Events.AVAILABILITY, + CalendarContract.Events.ACCESS_LEVEL, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.DESCRIPTION, + ) + + const val IDX_ID = 0 + const val IDX_ORIGINAL_INSTANCE_TIME = 1 + const val IDX_STATUS = 2 + const val IDX_TITLE = 3 + const val IDX_ALL_DAY = 4 + const val IDX_DTSTART = 5 + const val IDX_DTEND = 6 + const val IDX_DURATION = 7 + const val IDX_EVENT_TIMEZONE = 8 + const val IDX_AVAILABILITY = 9 + const val IDX_ACCESS_LEVEL = 10 + const val IDX_LOCATION = 11 + const val IDX_DESCRIPTION = 12 +} + internal object AttendeeProjection { val COLUMNS: Array = arrayOf( CalendarContract.Attendees.ATTENDEE_NAME, 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 66944f1..ad5b9fb 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 @@ -618,15 +618,16 @@ private fun EventEditContent( Spacer(Modifier.height(gap)) - // 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). + // Calendar card — tap anywhere to pick the target calendar. Picking a + // different one while editing *moves* the event (copy+delete on save, + // since CALENDAR_ID can't be updated in place). Managed special-dates + // events stay locked: the contact sync owns their calendar. EditCard( icon = Icons.Default.CalendarMonth, iconContentDescription = stringResource(R.string.event_detail_calendar), iconTint = accent, onClick = { showCalendarPicker = true } - .takeIf { state.calendars.isNotEmpty() && !state.isEditing }, + .takeIf { state.calendars.isNotEmpty() && !state.isManaged }, ) { Text( text = selectedCalendar?.displayName 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 e2da425..2de8b1e 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 @@ -439,10 +439,15 @@ class EventEditViewModel @Inject constructor( _saveState.value = SaveUiState.Saved return } + // Changing the calendar moves the event (copy+delete — CALENDAR_ID can't + // be updated in place) and is inherently whole-series: an occurrence + // can't live in a different calendar than its series, so a move skips the + // scope dialog even for a recurring event. + val movingCalendar = target != null && form.calendarId != target.original.calendarId // Managed events are a yearly series whose editable fields (reminders, // notes, location) live on the series row — never offer the scope dialog, // which would split the series into an exception the sync then reverts. - if (target != null && target.original.rrule != null && !current.isManaged) { + if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) { _saveState.value = SaveUiState.AwaitingScope return } @@ -497,6 +502,17 @@ class EventEditViewModel @Inject constructor( if (target == null) { repository.createEvent(form) prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) + } else if (form.calendarId != target.original.calendarId) { + // Move to the picked calendar (copy+delete), carrying any + // field edits made in the same save. The target becomes the + // last-used calendar, like a create does. + repository.moveEvent( + eventId = target.eventId, + targetCalendarId = requireNotNull(form.calendarId), + original = target.original, + updated = form, + ) + prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) } else { when (scope) { RecurringWriteScope.ThisEvent -> 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 ed8e728..14fa836 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 @@ -312,6 +312,46 @@ class CalendarRepositoryImplTest { assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated)) } + @Test + fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { nextInsertId = 77L } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(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(calendarId = 3L) + + val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated) + + assertThat(newId).isEqualTo(77L) + assertThat(fake.movedEvents).containsExactly( + FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated), + ) + } + + @Test + fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { + writeError = WriteFailedException("insert moved event into calendar id=3") + } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(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.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L)) + error("Expected WriteFailedException") + } catch (expected: WriteFailedException) { + assertThat(expected.message).contains("3") + } + } + @Test fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest { val fake = FakeCalendarDataSource().apply { 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 f1be42b..85d8956 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 @@ -298,4 +298,114 @@ class EventWriteMapperTest { assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null) assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null) } + + // --- buildMovedMasterValues (calendar move: verbatim series copy) --- + + private fun masterSnapshot( + isAllDay: Boolean = false, + dtStartMillis: Long = 1_781_164_800_000L, + dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L, + duration: String? = null, + rrule: String? = null, + rdate: String? = null, + exdate: String? = null, + ) = MasterEventSnapshot( + title = "Standup", + isAllDay = isAllDay, + dtStartMillis = dtStartMillis, + dtEndMillis = dtEndMillis, + duration = duration, + rrule = rrule, + rdate = rdate, + exdate = exdate, + timezone = "Europe/Berlin", + availability = CalendarContract.Events.AVAILABILITY_BUSY, + accessLevel = CalendarContract.Events.ACCESS_DEFAULT, + status = CalendarContract.Events.STATUS_CONFIRMED, + location = "Room 1", + description = "", + ) + + @Test + fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() { + val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula") + assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L) + assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula") + 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).doesNotContainKey(CalendarContract.Events.RRULE) + assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) + // Empty description clears explicitly; a raw/keyed colour is never copied + // (may be invalid on the target account — the copy inherits its colour). + assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY) + } + + @Test + fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() { + val values = buildMovedMasterValues( + masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"), + targetCalendarId = 9L, + uid = "u@calendula", + ) + assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY") + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z") + // Recurring rows never carry DTEND (the provider's invariant). + assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND) + } + + @Test + fun `moved series without a stored duration derives it from DTEND`() { + val values = buildMovedMasterValues( + masterSnapshot(duration = null, rrule = "FREQ=DAILY"), + targetCalendarId = 9L, + uid = "u@calendula", + ) + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + } + + // --- buildCopiedExceptionValues (calendar move: exception replay) --- + + private fun exceptionSnapshot( + isCancelled: Boolean = false, + duration: String? = null, + // Occurrence starts one hour into the day and runs 30 minutes. + dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L, + ) = ExceptionRowSnapshot( + exceptionEventId = 42L, + originalInstanceMillis = 1_781_164_800_000L, + isCancelled = isCancelled, + status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED, + title = "Moved occurrence", + isAllDay = false, + dtStartMillis = 1_781_164_800_000L + 3_600_000L, + dtEndMillis = dtEndMillis, + duration = duration, + timezone = "Europe/Berlin", + availability = CalendarContract.Events.AVAILABILITY_BUSY, + accessLevel = CalendarContract.Events.ACCESS_DEFAULT, + location = "", + description = "Notes", + ) + + @Test + fun `copied modified occurrence carries the original instance and DURATION not DTEND`() { + val values = buildCopiedExceptionValues(exceptionSnapshot()) + assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME]) + .isEqualTo(1_781_164_800_000L) + assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L) + // 30-minute occurrence derived from DTEND, written as DURATION only. + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S") + assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND) + assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null) + } + + @Test + fun `copied modified occurrence keeps a stored duration verbatim`() { + val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null)) + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S") + } } 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 d62ddae..dce1a32 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 @@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource { val insertedForms = mutableListOf() val updatedEvents = mutableListOf>() + data class MovedEvent( + val eventId: Long, + val targetCalendarId: Long, + val original: EventForm, + val updated: EventForm, + ) + val movedEvents = mutableListOf() val updatedOccurrences = mutableListOf>() val updatedFromOccurrences = mutableListOf>() val deletedEventIds = mutableListOf() @@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource { allDayReminderTimes += allDayReminderTimeMinutes } + override fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + allDayReminderTimeMinutes: Int, + ): Long { + writeError?.let { throw it } + movedEvents += MovedEvent(eventId, targetCalendarId, original, updated) + allDayReminderTimes += allDayReminderTimeMinutes + return nextInsertId + } + override fun updateOccurrence( eventId: Long, beginMillis: Long, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt new file mode 100644 index 0000000..51a9819 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -0,0 +1,158 @@ +package de.jeanlucmakiola.calendula.ui.edit + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.Dispatchers +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.time.Instant + +/** + * Focuses on the save-time branch that decides between an in-place update and a + * calendar *move* (copy+delete). The provider-level move itself is verified + * on-device; here the fake records which repository call the ViewModel chose. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EventEditViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) + @AfterEach fun tearDown() = Dispatchers.resetMain() + + private val beginMillis = 1_781_164_800_000L + private val endMillis = beginMillis + 3_600_000L + + private fun cal(id: Long): CalendarSource = CalendarSource( + id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL", + color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true, + ) + + private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail( + instance = EventInstance( + instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup", + start = Instant.fromEpochMilliseconds(beginMillis), + end = Instant.fromEpochMilliseconds(endMillis), + isAllDay = false, color = 0xFF000000.toInt(), location = null, + ), + description = null, organizer = null, attendees = emptyList(), rrule = rrule, + ) + + // Pin the DataStore's scope to the test dispatcher so a settings write (e.g. + // last-used calendar) completes under advanceUntilIdle instead of on a real + // IO thread the virtual clock can't observe. + private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() }, + ), + ) + + private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() }, + ), + ) + + private fun viewModel( + tempDir: Path, + fake: FakeCalendarDataSource, + ): EventEditViewModel { + val p = prefs(tempDir) + val s = settings(tempDir) + val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher) + return EventEditViewModel(repo, p, s, dispatcher) + } + + /** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */ + private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} } + + @Test + fun `changing the calendar routes the save through a move, not an update`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L), cal(2L)) + eventDetailResult = { detail(calendarId = 1L) } + nextInsertId = 99L + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + vm.setCalendar(2L) + vm.save() + advanceUntilIdle() + + assertThat(fake.movedEvents).hasSize(1) + assertThat(fake.movedEvents.single().eventId).isEqualTo(42L) + assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L) + assertThat(fake.updatedEvents).isEmpty() + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved) + job.cancel() + } + + @Test + fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L), cal(2L)) + eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") } + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + vm.setCalendar(2L) + vm.save() + advanceUntilIdle() + + // No AwaitingScope park: a move is inherently whole-series. + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved) + assertThat(fake.movedEvents).hasSize(1) + job.cancel() + } + + @Test + fun `editing a recurring event without moving still asks for the scope`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L), cal(2L)) + eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") } + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + vm.setTitle("Renamed") + vm.save() + advanceUntilIdle() + + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope) + assertThat(fake.movedEvents).isEmpty() + job.cancel() + } +}