Compare commits
3 Commits
60aa889c57
...
86ea72d56f
| Author | SHA1 | Date | |
|---|---|---|---|
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d |
@@ -3,13 +3,15 @@ name: Translations
|
|||||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
#
|
||||||
|
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||||
|
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||||
|
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||||
|
# required check pending forever and blocks the merge of any code-only PR into a
|
||||||
|
# release/* branch. The check itself is cheap and simply passes when the
|
||||||
|
# committed translations are consistent, so always running it costs nothing.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- 'app/src/main/res/values*/strings.xml'
|
|
||||||
- 'app/src/main/res/xml/locales_config.xml'
|
|
||||||
- 'scripts/check_translations.py'
|
|
||||||
- '.gitea/workflows/translations.yaml'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: translations-${{ github.ref }}
|
group: translations-${{ github.ref }}
|
||||||
|
|||||||
13
CHANGELOG.md
13
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/),
|
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).
|
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
|
## [2.14.0] — 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -874,3 +886,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
|
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
@@ -176,6 +178,25 @@ interface CalendarDataSource {
|
|||||||
allDayReminderTimeMinutes: Int,
|
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
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* 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<EventAttendee> = 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<ExceptionRowSnapshot> = 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(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ interface CalendarRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
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
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
* form's values); returns the exception's `Events._ID`.
|
* form's values); returns the exception's `Events._ID`.
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.deleteEvent(eventId)
|
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(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -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
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
* events, seconds otherwise.
|
* events, seconds otherwise.
|
||||||
*/
|
*/
|
||||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
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 {
|
} 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))
|
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<String, Any?> = 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<String, Any?> =
|
||||||
|
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
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -186,6 +186,86 @@ internal object SearchProjection {
|
|||||||
const val IDX_RDATE = 11
|
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<String> = 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<String> = 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 {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -618,15 +618,16 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
|
|
||||||
// Calendar card — tap anywhere to pick the target calendar. Editing
|
// Calendar card — tap anywhere to pick the target calendar. Picking a
|
||||||
// keeps the owning calendar (moving events between calendars is a
|
// different one while editing *moves* the event (copy+delete on save,
|
||||||
// sync-adapter minefield; every stock calendar app locks it too).
|
// since CALENDAR_ID can't be updated in place). Managed special-dates
|
||||||
|
// events stay locked: the contact sync owns their calendar.
|
||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.CalendarMonth,
|
icon = Icons.Default.CalendarMonth,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
iconTint = accent,
|
iconTint = accent,
|
||||||
onClick = { showCalendarPicker = true }
|
onClick = { showCalendarPicker = true }
|
||||||
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
|
|||||||
@@ -439,10 +439,15 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
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,
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
// notes, location) live on the series row — never offer the scope dialog,
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
// which would split the series into an exception the sync then reverts.
|
// 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
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -497,6 +502,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
repository.createEvent(form)
|
repository.createEvent(form)
|
||||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
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 {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
|||||||
@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
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
|
@Test
|
||||||
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
|||||||
@@ -298,4 +298,114 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
|
|
||||||
val insertedForms = mutableListOf<EventForm>()
|
val insertedForms = mutableListOf<EventForm>()
|
||||||
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||||
|
data class MovedEvent(
|
||||||
|
val eventId: Long,
|
||||||
|
val targetCalendarId: Long,
|
||||||
|
val original: EventForm,
|
||||||
|
val updated: EventForm,
|
||||||
|
)
|
||||||
|
val movedEvents = mutableListOf<MovedEvent>()
|
||||||
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
val deletedEventIds = mutableListOf<Long>()
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
allDayReminderTimes += allDayReminderTimeMinutes
|
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(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user