Drag events to reschedule them (#68)

Long-press an event block in the week or day timeline and drag it to another
time or day column; drag a chip in the month grid onto another date. The drop
runs through the edit screen's own write path, so recurring writes, reminders
and guests behave the same as a save.

Two provider traps the drag had to close:

- RRULE is written verbatim while DTSTART moves, so dragging a
  FREQ=WEEKLY;BYDAY=MO occurrence to a Wednesday under "all events" left a
  Wednesday anchor under a Monday rule and the series stayed put.
  realignRecurrence re-derives BYDAY/BYMONTHDAY/BYMONTH, and refuses rules one
  moved occurrence can't resolve (BYDAY=MO,WE, 2TH, BYSETPOS) — those may only
  move the single occurrence.
- Nothing below the UI refuses a write, so allowsEventMove gates the gesture.
  Not isEventTarget: a managed contact-mirror event is editable but must never
  move.

The pickup is hand-rolled rather than detectDragGesturesAfterLongPress: during
the 500ms hold the block consumes nothing, so the scroll and page swipe claim at
touch slop and kill the press, and the stock detector also cancels as soon as the
finger leaves a block that may only be 10dp tall. Ours holds with a 6dp tolerance
and never cancels on leaving bounds.

Agenda is out of scope (list rows, no positioned blocks), as is
resize-by-edge-drag. Month drags don't auto-scroll or page yet.
This commit is contained in:
2026-08-01 17:21:35 +02:00
parent 5764bd889b
commit aa24de443d
27 changed files with 2579 additions and 82 deletions

View File

@@ -125,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore(
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
eventColor = eventColor,
eventColorKey = eventColorKey,
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
)
}

View File

@@ -89,6 +89,11 @@ internal object EventDetailProjection {
// Recurring rows carry DURATION instead of DTEND; the detail screen
// needs it to render a series opened without a named occurrence.
CalendarContract.Events.DURATION,
// Non-null on a modified-occurrence exception row (it points at the
// series). "No RRULE" alone can't tell an exception from a master — a
// sync adapter that leaves the rule on the exception would otherwise
// get an exception written against an exception (#68).
CalendarContract.Events.ORIGINAL_ID,
)
const val IDX_EVENT_ID = 0
@@ -110,6 +115,7 @@ internal object EventDetailProjection {
const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17
const val IDX_DURATION = 18
const val IDX_ORIGINAL_ID = 19
}
/**

View File

@@ -44,6 +44,16 @@ val CalendarSource.hasVisibilitySwitch: Boolean
val CalendarSource.isEventTarget: Boolean
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
/**
* Whether this calendar's events may have their times rewritten by a drag (#68).
* Deliberately not [isEventTarget]: an event living in a switched-off or
* non-syncing calendar isn't rendered anyway, while a *managed* event is
* editable (reminders, notes) yet must never move — the next contacts sync would
* put it back.
*/
val CalendarSource.allowsEventMove: Boolean
get() = canModifyContents && !isManaged
/** Every state worth naming on this calendar's row, in reading order. */
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
if (isManaged) add(CalendarStateLabel.MANAGED)

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
* The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
* An unparseable pinned id falls back to the device, like the write path does.
*/
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
/**
* The form moved so it starts at [newStart], keeping its length. The wall-clock
* values are re-derived in the event's own zone, not the device's, so dragging a
* pinned event still means what the event means.
*
* The **instant** duration is preserved, not the wall-clock span: a recurring
* event's length travels to the provider as `DURATION`, so preserving wall clock
* would rewrite a whole series' length whenever a shift crosses a DST boundary.
*
* All-day events carry placeholder times and are date-anchored — move them with
* [shiftedByDays] instead; this returns them untouched.
*/
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(
start = newStart.toLocalDateTime(zone),
end = (newStart + span).toLocalDateTime(zone),
)
}
/**
* The form moved [days] calendar days, keeping its time of day and its length.
* Both ends move, so a multi-day event keeps its span rather than collapsing to
* a single day; an all-day event's placeholder times ride along untouched.
*/
fun EventForm.shiftedByDays(days: Int): EventForm {
if (days == 0) return this
return copy(
start = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time),
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
)
}

View File

@@ -124,6 +124,13 @@ data class EventDetail(
val eventColor: Int? = null,
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
val eventColorKey: String? = null,
/**
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
* set), not a master. Such a row stands alone — writing an exception against
* it would nest one exception inside another — so a reschedule always takes
* the plain whole-row path, whatever [rrule] a sync adapter left on it.
*/
val isException: Boolean = false,
)
/**

View File

@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
}
}
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU",
DayOfWeek.WEDNESDAY to "WE",

View File

@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
/**
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
*
* `Events.RRULE` is written verbatim while DTSTART moves, so a rule that names
* its own day — `FREQ=WEEKLY;BYDAY=MO`, what Google and CalDAV write for nearly
* every weekly series — would keep pointing at Monday after the anchor became a
* Wednesday, and the series would not move at all. The day-selecting parts are
* therefore re-derived from [newStart].
*
* Returns null when the rule picks days in a way one moved occurrence cannot
* resolve (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …). There is no safe
* guess there — dropping the extra days would delete occurrences — so the caller
* refuses to move anything but that single occurrence.
*/
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
if (parts.isEmpty()) return null
val rebuilt = parts.map { part ->
val eq = part.indexOf('=')
if (eq <= 0) return null
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
"BYDAY" -> {
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
}
"BYMONTHDAY" -> {
if (value != oldStart.day.toString()) return null
"BYMONTHDAY=${newStart.day}"
}
"BYMONTH" -> {
if (value != oldStart.month.number.toString()) return null
"BYMONTH=${newStart.month.number}"
}
"FREQ", "INTERVAL", "COUNT", "UNTIL", "WKST" -> part
else -> return null
}
}
return prefix + rebuilt.joinToString(";")
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -27,6 +28,10 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
@@ -294,6 +299,27 @@ fun CalendarHost(
}
}
// Drag to reschedule (#68). Hosted here so one instance serves every view and
// survives view switches; the calendar surfaces read it out of LocalEventMove.
val reschedule: RescheduleViewModel = hiltViewModel()
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
val onEditEvent: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldEditKey = key
editKey = key
}
val moveScope = remember(movableCalendarIds, reschedule) {
EventMoveScope(
movableCalendarIds = movableCalendarIds,
move = reschedule::move,
edit = onEditEvent,
)
}
val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
@@ -311,6 +337,7 @@ fun CalendarHost(
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = fadeThrough()
CompositionLocalProvider(LocalEventMove provides moveScope) {
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
@@ -367,6 +394,12 @@ fun CalendarHost(
)
}
}
}
// Scope prompt + confirmation/undo snackbar for a dropped event. Declared
// right after the calendar views, so any overlay opened afterwards covers
// the snackbar rather than the other way round.
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.

View File

@@ -0,0 +1,165 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
/**
* How far the finger may wander during the hold. Deliberately well under touch
* slop (~18dp): past that an ancestor — the vertical scroll, the page swipe —
* claims the gesture, and a hold that survived to there would have opened a dead
* zone where neither the drag nor the page turn happens.
*/
private val PICKUP_TOLERANCE = 6.dp
/**
* Pick an event block up with a long press and drag it, without
* `detectDragGesturesAfterLongPress`.
*
* The stock detector cancels the press as soon as an ancestor consumes — and
* during the hold the block itself consumes nothing, so the scroll and the page
* swipe are free to claim at their own slop — and it also cancels when the finger
* leaves the block, which a `MIN_EVENT_FRACTION`-tall block loses immediately.
* This one keeps its own timeout and its own (much smaller) tolerance, and never
* cancels on leaving the bounds.
*
* Movement before the timeout is deliberately **not** consumed: consuming it
* would kill the ancestor's gesture outright, so a scroll that happens to start
* on top of a block would die. Fast movement means the user meant to scroll —
* abandon quietly. A second finger means a pinch, which owns the gesture.
*/
@Composable
fun rememberEventDragSource(
enabled: Boolean,
key: Any?,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
): Modifier = rememberDragSurface(
enabled = enabled,
key = key,
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
onPickUp(pointerInRoot, nodeInRoot, size)
true
},
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
)
/**
* The same pickup, for a surface that carries many draggable pieces rather than
* being one itself — the month grid, whose chips are covered by a full-bleed tap
* layer and so can never take pointer input of their own. [onPickUp] receives the
* press position local to this node and answers whether anything is there;
* returning false abandons the gesture as if the hold had never completed.
*/
@Composable
fun rememberDragSurface(
enabled: Boolean,
key: Any?,
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
): Modifier {
if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val currentPickUp by rememberUpdatedState(onPickUp)
val currentMove by rememberUpdatedState(onMove)
val currentDrop by rememberUpdatedState(onDrop)
val currentCancel by rememberUpdatedState(onCancel)
return Modifier
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(key) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val origin = down.position
val heldStill = withTimeoutOrNull(holdMillis) {
while (true) {
val main = awaitPointerEvent()
val change = main.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
if (main.changes.count { it.pressed } > 1) break
if ((change.position - origin).getDistance() > tolerance) break
// An ancestor's claim only becomes visible once the whole
// main pass has run, so look again on the final pass.
val final = awaitPointerEvent(PointerEventPass.Final)
if (final.changes.any { it.isConsumed }) break
}
} == null
if (!heldStill) return@awaitEachGesture
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
val took = currentPickUp(
down.position,
layout.localToRoot(down.position),
layout.positionInRoot(),
layout.size,
)
if (!took) return@awaitEachGesture
var dropped = false
try {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
dropped = true
break
}
// A second finger is the pinch taking over; it consumes on
// the initial pass, so the drag is already dead.
if (event.changes.count { it.pressed } > 1) break
change.consume()
coordinates[0]?.takeIf { it.isAttached }
?.let { currentMove(it.localToRoot(change.position)) }
}
} finally {
// Also reached when the pointer node is disposed mid-drag (the
// page swapping out under the finger) — that is a cancel, not a
// drop, and must never write.
if (dropped) currentDrop() else currentCancel()
}
}
}
}
/**
* The app's first haptics: a lift on pickup, then a tick every time the drop
* target snaps to a different slot, so the granularity is felt rather than read.
*/
@Composable
fun DragSnapHaptics(slot: Any?) {
val haptics = LocalHapticFeedback.current
val previous = remember { arrayOfNulls<Any>(1) }
LaunchedEffect(slot) {
val had = previous[0]
previous[0] = slot
when {
slot == null -> Unit
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
}

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Locale
/**
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
* recurring-scope prompt, and the confirmation snackbar carrying Undo.
*
* None of the four calendar screens sets a `snackbarHost` on its Scaffold, so
* this hosts its own — bottom-centre inside the host's root Box, above every
* view. It forgoes the Scaffold's FAB avoidance in exchange for not threading a
* `SnackbarHostState` through all four screens.
*/
@Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
val snackbars = remember { SnackbarHostState() }
val locale = currentLocale()
val use24Hour = LocalUse24HourFormat.current
prompt?.let { pending ->
RecurringScopeDialog(
title = stringResource(R.string.event_move_recurring_title),
onSelect = viewModel::moveWithScope,
onDismiss = viewModel::cancelScope,
allowSeries = !pending.occurrenceOnly,
reason = stringResource(R.string.event_move_occurrence_only)
.takeIf { pending.occurrenceOnly },
)
}
val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val message = when (val o = outcome) {
null -> null
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
}
val undoLabel = stringResource(R.string.event_move_undo)
LaunchedEffect(outcome) {
val text = message ?: return@LaunchedEffect
val undo = moved?.undo
val result = snackbars.showSnackbar(
message = text,
actionLabel = undoLabel.takeIf { undo != null },
withDismissAction = undo == null,
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed && undo != null) {
viewModel.undo(undo)
} else {
viewModel.consumeOutcome()
}
}
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
SnackbarHost(
hostState = snackbars,
modifier = Modifier.navigationBarsPadding().padding(16.dp),
)
}
}
/**
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
* are read back on the UTC calendar day they are anchored to (#65, #82).
*/
private fun formatMovedTo(
startMillis: Long,
isAllDay: Boolean,
use24Hour: Boolean,
locale: Locale,
): String {
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
val skeleton = when {
isAllDay -> "EEEdMMM"
use24Hour -> "EEEdMMMHm"
else -> "EEEdMMMhm"
}
return localizedDateFormatter(locale, skeleton)
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
}

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
/**
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
* whichever event block is being composed. A composition local rather than six
* layers of parameters: every calendar surface needs the same three things, and
* the blocks that need them sit deep inside private composables.
*
* Null means moving is off entirely (no host provided it) — a block then
* registers no drag gesture at all, so a long press keeps its old meaning of
* nothing happening.
*/
@Immutable
class EventMoveScope(
/**
* Calendars whose events may be moved. Nothing below the UI enforces this —
* the repository writes whatever it is handed — so this gate is load-bearing.
*/
val movableCalendarIds: Set<Long>,
val move: (MoveRequest) -> Unit,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
}
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
/** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f
/**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved — the block then carries no
* action and registers no drag either.
*/
@Composable
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
val move = LocalEventMove.current ?: return null
if (!move.allows(event)) return null
val label = stringResource(R.string.event_move_action)
return remember(event.instanceId, label, move) {
CustomAccessibilityAction(label) {
move.edit(event)
true
}
}
}

View File

@@ -0,0 +1,73 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.floret.components.OptionCard
/**
* How far a write to a recurring event should reach: this occurrence, it and
* everything after (a series split), or the whole series.
*
* One of the two carve-outs from the full-screen picker rule — a two-or-three
* option decision taken mid-action reads better as a popup than as a near-empty
* screen. Shared by the edit screen's save and a drag-and-drop reschedule, which
* want the same three options for the same reasons.
*
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
* rule, so a changed recurrence rules it out); [allowSeries] drops the two wider
* options, for a rule whose days can't be recalculated from one moved
* occurrence — [reason] then says why.
*/
@Composable
fun RecurringScopeDialog(
title: String,
onSelect: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
allowOccurrence: Boolean = true,
allowSeries: Boolean = true,
reason: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (reason != null) {
Text(
text = reason,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (allowOccurrence) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
)
}
if (allowSeries) {
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { onSelect(RecurringWriteScope.AllEvents) },
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}

View File

@@ -0,0 +1,341 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
/** Where a dragged event should land. */
sealed interface MoveTarget {
/** A new start instant — a timeline drag, which moves time and day at once. */
data class Start(val instant: Instant) : MoveTarget
/** A new first day, keeping the time of day — a month-grid or all-day drag. */
data class Day(val date: LocalDate) : MoveTarget
}
/**
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
* own times (`Instances.BEGIN`/`END`), exactly as the detail and edit screens
* pass them, so a recurring series resolves the right occurrence.
*/
data class MoveRequest(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val target: MoveTarget,
)
/**
* A recurring drop waiting for the user to pick how far it reaches.
* [occurrenceOnly] means the rule names days that a single moved occurrence
* can't re-derive (`BYDAY=MO,WE`, `2TH`, …), so the only honest option left is
* this one occurrence — see [realignRecurrence].
*/
data class MoveScopePrompt(val occurrenceOnly: Boolean)
/** The inverse of a completed move, for the undo action. */
data class MoveUndo(
val eventId: Long,
/** The form as it now stands (the moved state). */
val moved: EventForm,
/** The form as it stood before the move. */
val restored: EventForm,
)
sealed interface MoveOutcome {
/**
* Written. [startMillis] is where the dragged occurrence now begins, for the
* confirmation message; [undo] is null when the write has no clean inverse.
*/
data class Moved(
val startMillis: Long,
val isAllDay: Boolean,
val undo: MoveUndo?,
) : MoveOutcome
data object Undone : MoveOutcome
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
data object WriteDenied : MoveOutcome
/** The event vanished (sync, another device) between the read and the write. */
data object Gone : MoveOutcome
/** The drop would push the series past its own `UNTIL`, generating nothing. */
data object BlockedSeriesEnd : MoveOutcome
data object Failed : MoveOutcome
}
/**
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
* edit screen's save uses, so recurring writes, reminder reconciliation and
* attendee preservation behave identically. Hosted at `CalendarHost` so one
* instance serves every calendar view and survives view switches.
*
* The full prefilled form is carried through the write — never a stripped one —
* because the occurrence-exception path reconciles reminders and attendees onto
* the new row, and a partial form would wipe them.
*/
@HiltViewModel
class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
private var pending: PreparedMove? = null
/**
* The calendars whose events may be dragged. Nothing below the UI guards
* this — the repository and data source attempt any write handed to them —
* so a block outside this set registers no drag gesture at all.
*/
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
.catch { emit(emptySet<Long>()) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
/** Everything a drop needs, resolved before anything is written. */
private data class PreparedMove(
val request: MoveRequest,
val original: EventForm,
val updated: EventForm,
/** True for a series master with a rule — an exception row is not one. */
val isRecurring: Boolean,
/**
* False when the rule names days this move can't re-derive, so anything
* wider than the single occurrence would leave rule and anchor disagreeing.
*/
val canRealign: Boolean,
)
fun move(request: MoveRequest) {
if (_scopePrompt.value != null) return
viewModelScope.launch {
val prepared = prepare(request) ?: return@launch
if (!prepared.isRecurring) {
write(prepared, RecurringWriteScope.AllEvents)
} else {
pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
}
}
}
/** Answer the scope dialog. */
fun moveWithScope(scope: RecurringWriteScope) {
val prepared = pending ?: return
pending = null
_scopePrompt.value = null
viewModelScope.launch { write(prepared, scope) }
}
/** Dismiss the scope dialog without writing. */
fun cancelScope() {
pending = null
_scopePrompt.value = null
}
/** Put a completed move back where it came from. */
fun undo(undo: MoveUndo) {
_outcome.value = null
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
MoveOutcome.Undone
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
MoveOutcome.WriteDenied
} catch (e: NoSuchEventException) {
MoveOutcome.Gone
} catch (e: Exception) {
MoveOutcome.Failed
}
}
}
/** Clear the outcome once the screen has shown it. */
fun consumeOutcome() {
_outcome.value = null
}
private suspend fun prepare(request: MoveRequest): PreparedMove? {
val detail = try {
repository.eventDetail(request.eventId)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_outcome.value = MoveOutcome.Gone
return null
} catch (e: Exception) {
_outcome.value = MoveOutcome.Failed
return null
}
val zone = TimeZone.currentSystemDefault()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.Day -> original.shiftedByDays(
(
target.date.toEpochDays() -
sourceDate(request.beginMillis, original.isAllDay, zone).toEpochDays()
).toInt(),
)
}
// A zero-distance drop is not a write. Matches the edit form's own
// pristine-form no-op, and keeps a mis-aimed long press harmless.
if (shifted == original) return null
val problems = shifted.problems()
if (EventFormProblem.RecurrenceEndsBeforeStart in problems) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
return null
}
if (problems.isNotEmpty()) {
_outcome.value = MoveOutcome.Failed
return null
}
// An exception row stands alone whatever rule a sync adapter left on it.
val isRecurring = original.rrule != null && !detail.isException
val movedDay = shifted.start.date != original.start.date
val realigned = if (isRecurring && movedDay) {
realignRecurrence(
requireNotNull(original.rrule),
original.start.date,
shifted.start.date,
)
} else {
original.rrule
}
return PreparedMove(
request = request,
original = original,
// A rule we can't re-derive stays verbatim: the only scope offered
// then is the single occurrence, whose exception row carries no rule.
updated = shifted.copy(rrule = realigned ?: original.rrule),
isRecurring = isRecurring,
canRealign = !isRecurring || !movedDay || realigned != null,
)
}
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
val request = prepared.request
_outcome.value = try {
when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
scope == RecurringWriteScope.ThisEvent ->
repository.updateOccurrence(
request.eventId,
request.beginMillis,
prepared.updated,
)
else -> repository.updateEventFromOccurrence(
eventId = request.eventId,
beginMillis = request.beginMillis,
original = prepared.original,
updated = prepared.updated,
)
}
MoveOutcome.Moved(
startMillis = movedStartMillis(prepared),
isAllDay = prepared.updated.isAllDay,
undo = undoFor(prepared, scope),
)
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
MoveOutcome.WriteDenied
} catch (e: NoSuchEventException) {
MoveOutcome.Gone
} catch (e: Exception) {
MoveOutcome.Failed
}
}
/**
* Undo is offered only where the inverse is one symmetric write: a
* non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the
* −Δ wall-clock shift lands on the re-read anchor). "This event" leaves an
* exception row behind and "this and following" splits the series with an
* UNTIL truncation — neither is undone by shifting back.
*/
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
MoveUndo(
eventId = prepared.request.eventId,
moved = prepared.updated,
restored = prepared.original,
)
} else {
null
}
/**
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: a UTC midnight for an all-day event, the real instant
* for a timed one.
*/
private fun movedStartMillis(prepared: PreparedMove): Long {
val form = prepared.updated
return if (form.isAllDay) {
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
} else {
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
}
}
/** The calendar day the dragged occurrence started on, as the grid shows it. */
private fun sourceDate(beginMillis: Long, isAllDay: Boolean, zone: TimeZone): LocalDate =
Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(if (isAllDay) TimeZone.UTC else zone)
.date
}

View File

@@ -0,0 +1,323 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt
import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
const val DRAG_SNAP_MINUTES: Int = 15
/** How close to a timeline edge the finger must get before the view scrolls. */
private val AUTO_SCROLL_EDGE = 64.dp
/** Fastest auto-scroll step, per frame, right at the edge. */
private val AUTO_SCROLL_STEP = 16.dp
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
data class TimelineDrag(
val event: EventInstance,
val date: LocalDate,
val startMin: Int,
val endMin: Int,
val topLeftInRoot: Offset,
val sizePx: IntSize,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin
}
/** Where a finished drag asks its event to go. */
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
/**
* The timeline's live geometry, republished on every layout. Held in plain
* fields rather than snapshot state on purpose: it changes on every scroll
* frame, and recomposing the screen that often would cost far more than the
* drag loop's own per-frame read of it.
*/
class TimelineGeometry {
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
var grid: LayoutCoordinates? = null
/** The scroll viewport, for the edge that triggers auto-scrolling. */
var viewport: LayoutCoordinates? = null
var scroll: ScrollState? = null
var hourPx: Float = 0f
/** Column pitch — one column plus the gap after it. */
var columnWidthPx: Float = 0f
var columnGapPx: Float = 0f
var edgePx: Float = 0f
var stepPx: Float = 0f
var days: List<LocalDate> = emptyList()
}
/**
* Hoisted drag state for one timeline (#68). It lives above the per-page
* `AnimatedContent` — a page change mid-drag would otherwise strand a ghost —
* and the block it renders is drawn in an overlay: a `Card` column ends its
* modifier chain with a clip, so a block offset toward the neighbouring column
* would simply be cut off in place.
*/
@Stable
class TimelineDragController {
val geometry = TimelineGeometry()
var drag: TimelineDrag? by mutableStateOf(null)
private set
/**
* Which block is lifted, and whether anything is. Separate snapshot state
* from [drag] on purpose: [drag] changes on every frame, and the blocks that
* only need to know "am I the ghost" must not recompose that often.
*/
var liftedInstanceId: Long? by mutableStateOf(null)
private set
private var source: TimedBlock? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
val isDragging: Boolean get() = liftedInstanceId != null
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
source = block
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
pointer = pointerInRoot
recompute()
}
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
recompute()
}
fun cancel() {
source = null
liftedInstanceId = null
drag = null
}
/** End the drag, handing back where it landed (null if it never resolved). */
fun finish(): TimelineDrop? {
val landed = drag
cancel()
return landed?.let { TimelineDrop(it.event, it.date, it.startMin) }
}
/**
* Re-derive the target from the pointer's *root* position. Recomputed rather
* than accumulated from `positionChange()`, because a stationary finger emits
* no move events while auto-scroll walks the content underneath it.
*/
fun recompute() {
val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
// Clamp the start into the target day; a tail running past midnight is
// fine and stays visible on the next day.
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val span = block.endMin - block.startMin
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
drag = TimelineDrag(
event = block.event,
date = days[column],
startMin = startMin,
endMin = startMin + span,
topLeftInRoot = Offset(
x = origin.x + column * columnPx,
y = origin.y + startMin / 60f * hourPx,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
height.roundToInt(),
),
)
}
/**
* Hold the scroll for the whole drag and nudge it once per frame while the
* finger sits near an edge. One `scroll` call, not a loop of
* `animateScrollBy`: each of those re-acquires the mutex and cancels the last.
*/
suspend fun autoScroll() {
val scroll = geometry.scroll ?: return
scroll.scroll(MutatePriority.UserInput) {
while (true) {
withFrameNanos { }
recompute()
val step = edgeStep()
if (step != 0f) scrollBy(step)
}
}
}
private fun edgeStep(): Float {
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
val edge = geometry.edgePx
if (edge <= 0f) return 0f
val bounds = viewport.boundsInRoot()
val fromTop = pointer.y - bounds.top
val fromBottom = bounds.bottom - pointer.y
return when {
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
else -> 0f
}
}
}
@Composable
fun rememberTimelineDragController(): TimelineDragController {
val controller = remember { TimelineDragController() }
val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
return controller
}
/**
* True when [block] actually begins on [day] rather than being the tail of an
* event that started earlier. A clipped block's top edge is midnight, not the
* event's start, so dragging it would move the event to a time it never had.
*/
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
event.start.toLocalDateTime(zone).date == day
/**
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
* forward by the missing hour, and the block visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
/**
* The floating block, drawn over the whole calendar so it is free of the day
* column's clip and of the scroll viewport's rounded corners.
*/
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val density = LocalDensity.current
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot)
LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll()
}
Box(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned { origin = it.positionInRoot() },
) {
val drag = controller.drag ?: return@Box
val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" +
formatMinuteOfDay(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale)
Box(
modifier = Modifier
.offset {
IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(),
)
}
.size(
width = with(density) { drag.sizePx.width.toDp() },
height = with(density) { drag.sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
.graphicsLayer {
if (!reduceMotion) {
scaleX = 1.02f
scaleY = 1.02f
}
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(4.dp)
clip = false
}
.background(fill, RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}
}
}

View File

@@ -53,11 +53,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -74,7 +77,19 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -298,31 +313,51 @@ private fun DayContent(
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
AnimatedContent(
targetState = state,
modifier = modifier.then(swipeModifier),
contentKey = { s ->
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
Box(modifier = modifier) {
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
)
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
}
TimelineDragOverlay(dragController)
}
}
@@ -332,8 +367,10 @@ private fun DaySuccess(
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
@@ -352,8 +389,10 @@ private fun DaySuccess(
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
@@ -487,13 +526,16 @@ private fun AllDayBar(
private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -544,7 +586,8 @@ private fun Timeline(
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
@@ -552,11 +595,25 @@ private fun Timeline(
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
}
},
)
}
}
@@ -570,8 +627,10 @@ private fun DayColumnCard(
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
@@ -613,7 +672,10 @@ private fun DayColumnCard(
block = block,
dark = dark,
height = height,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
@@ -634,7 +696,10 @@ private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
date: LocalDate,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -658,12 +723,33 @@ private fun EventBlock(
val showTitle = available >= titleLineHeight
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge is
// midnight, not the event's start, so dragging it would invent a time.
val dragModifier = rememberEventDragSource(
enabled = moveAction != null && block.beginsOn(date, zone),
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = dragController.liftedInstanceId == block.event.instanceId
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier)
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {

View File

@@ -127,6 +127,7 @@ import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
@@ -452,30 +453,11 @@ private fun SaveScopeDialog(
onSelect: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!recurrenceChanged) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
)
}
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { onSelect(RecurringWriteScope.AllEvents) },
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
RecurringScopeDialog(
title = stringResource(R.string.event_edit_recurring_title),
onSelect = onSelect,
onDismiss = onDismiss,
allowOccurrence = !recurrenceChanged,
)
}

View File

@@ -0,0 +1,148 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/** One week row's live geometry, republished on every layout while it is on screen. */
class MonthRowGeometry(
val days: List<LocalDate>,
/** The row's day-column box — the space chip offsets are measured in. */
val cell: LayoutCoordinates,
val columnWidthPx: Float,
)
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag(
val event: EventInstance,
/** The day whose column the chip was grabbed in — the drag is a delta from it. */
val grabDate: LocalDate,
val targetDate: LocalDate?,
val topLeftInRoot: Offset,
val sizePx: IntSize,
)
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
data class MonthChipDrop(
val event: EventInstance,
val grabDate: LocalDate,
val targetDate: LocalDate,
)
/**
* Hoisted drag state for the month grid (#68).
*
* Every visible week row registers itself, so a chip can be carried across rows:
* the pointer node that took the press keeps receiving events after the finger
* has left its own bounds, and the target is then resolved against whichever
* registered row the finger is actually over. Rows are keyed by an identity token
* rather than by their date, because the continuous style can show the same week
* twice — once in each adjoining month.
*/
@Stable
class MonthDragController {
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
var drag: MonthChipDrag? by mutableStateOf(null)
private set
/**
* Which chip is lifted, and whether anything is. Separate snapshot state from
* [drag] on purpose: [drag] changes on every frame, while the chips and rows
* that only need "am I the ghost" / "must I stop clipping" must not.
*/
var liftedInstanceId: Long? by mutableStateOf(null)
private set
private var event: EventInstance? = null
private var grabDate: LocalDate? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
private var sizePx = IntSize.Zero
val isDragging: Boolean get() = liftedInstanceId != null
fun putRow(token: Any, geometry: MonthRowGeometry) {
rows[token] = geometry
}
fun removeRow(token: Any) {
rows.remove(token)
}
fun begin(
event: EventInstance,
grabDate: LocalDate,
pointerInRoot: Offset,
chipInRoot: Offset,
size: IntSize,
) {
this.event = event
this.grabDate = grabDate
liftedInstanceId = event.instanceId
grab = pointerInRoot - chipInRoot
pointer = pointerInRoot
sizePx = size
recompute()
}
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
recompute()
}
fun cancel() {
event = null
grabDate = null
liftedInstanceId = null
drag = null
}
fun finish(): MonthChipDrop? {
val landed = drag
cancel()
val target = landed?.targetDate ?: return null
return MonthChipDrop(landed.event, landed.grabDate, target)
}
private fun recompute() {
val event = event ?: return
val grabbed = grabDate ?: return
val resolved = rows.values.firstNotNullOfOrNull { row ->
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
?: return@firstNotNullOfOrNull null
if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) {
null
} else {
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
.toInt()
.coerceIn(0, row.days.lastIndex)
row.days[column]
}
}
drag = MonthChipDrag(
event = event,
grabDate = grabbed,
// Off the grid entirely (the gutter, the header): keep the last cell
// it was over, so a wobble past the edge doesn't drop the target.
targetDate = resolved ?: drag?.targetDate,
topLeftInRoot = pointer - grab,
sizePx = sizePx,
)
}
}
@Composable
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }

View File

@@ -62,6 +62,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableIntStateOf
@@ -78,6 +79,23 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
@@ -384,13 +402,21 @@ fun MonthScreen(
)
},
) { innerPadding ->
Column(
// Hoisted above every style's grid: a dragged chip is drawn in an
// overlay so it is free of the week row's clip and, in the scrolling
// styles, of the list viewport's.
val chipDrag = rememberMonthDragController()
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
Column(modifier = Modifier.fillMaxSize()) {
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
CompositionLocalProvider(
LocalDimCutoff provides dimCutoff,
LocalMonthDrag provides chipDrag,
) {
if (scrolling) {
ContinuousMonthContent(
state = continuousState,
@@ -427,10 +453,56 @@ fun MonthScreen(
}
}
}
MonthDragOverlay(chipDrag)
}
}
}
}
/** The chip in flight, drawn over the grid and following the finger. */
@Composable
private fun MonthDragOverlay(controller: MonthDragController) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme()
val density = LocalDensity.current
val reduceMotion = rememberReduceMotion()
// Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate)
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { origin = it.positionInRoot() },
) {
val drag = controller.drag ?: return@Box
MonthBar(
event = drag.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
modifier = Modifier
.offset {
IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.graphicsLayer {
if (!reduceMotion) {
scaleX = 1.04f
scaleY = 1.04f
}
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(4.dp)
clip = false
},
)
}
}
@Composable
private fun MonthContent(
state: MonthUiState,
@@ -1720,6 +1792,22 @@ private fun MonthWeekRow(
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight()
// Drag to reschedule (#68). The chips can take no pointer input of their own —
// the full-bleed tap layer below sits on top of them, and Compose stops
// sibling hit-testing at the topmost hit — so one detector on the row's day-
// column box hit-tests them geometrically instead.
val moveScope = LocalEventMove.current
val dragController = LocalMonthDrag.current
val rowToken = remember { Any() }
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val density = LocalDensity.current
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
val zone = remember { TimeZone.currentSystemDefault() }
val dragging = dragController?.isDragging == true
DisposableEffect(rowToken, dragController) {
onDispose { dragController?.removeRow(rowToken) }
}
Row(modifier) {
// Optional calendar-week gutter, sized so the seven day columns below
// divide the remaining width — the absolute bar offsets stay correct
@@ -1735,7 +1823,27 @@ private fun MonthWeekRow(
BoxWithConstraints(
Modifier
.weight(1f)
.fillMaxHeight(),
.fillMaxHeight()
.onGloballyPositioned { coords ->
dragController?.putRow(
rowToken,
MonthRowGeometry(
days = week.days,
cell = coords,
columnWidthPx = coords.size.width / 7f,
),
)
}
.then(
monthChipDragModifier(
week = week,
moveScope = moveScope,
controller = dragController,
band = bandCoordinates,
rowHeightPx = rowHeightPx,
zone = zone,
),
),
) {
val colW = maxWidth / 7
@@ -1800,7 +1908,10 @@ private fun MonthWeekRow(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.then(if (morphing) Modifier else Modifier.clipToBounds()),
.onGloballyPositioned { bandCoordinates[0] = it }
// A dragged chip travels to another row, so the clip has
// to yield for it exactly as it does for a morph.
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
) {
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span ->
@@ -1959,6 +2070,74 @@ private fun MonthWeekRow(
}
}
/**
* The row-level pickup for month chips: it resolves which chip the press landed
* on from the geometry the row just laid out, and abandons the gesture when the
* press was on empty space (or on an event whose calendar can't be moved), so
* tapping a day still opens it.
*/
@Composable
private fun monthChipDragModifier(
week: MonthWeek,
moveScope: EventMoveScope?,
controller: MonthDragController?,
band: Array<LayoutCoordinates?>,
rowHeightPx: Float,
zone: TimeZone,
): Modifier = rememberDragSurface(
enabled = moveScope != null && controller != null,
key = week.days.first(),
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
val columnPx = size.width / 7f
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
val lane = (bandY / rowHeightPx).toInt()
val column = (local.x / columnPx).toInt()
val event = if (bandY < 0f || columnPx <= 0f) {
null
} else {
week.chipAt(column, lane, MAX_EVENT_ROWS)
}
if (event == null || moveScope?.allows(event) != true) {
false
} else {
controller?.begin(
event = event,
grabDate = week.days[column],
pointerInRoot = pointerInRoot,
chipInRoot = Offset(
x = nodeInRoot.x + column * columnPx,
y = requireNotNull(bandTop) + lane * rowHeightPx,
),
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
)
true
}
},
onMove = { controller?.move(it) },
onDrop = {
controller?.finish()?.let { drop ->
val delta = drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()
if (delta != 0L) {
moveScope?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
// The event's own first day plus the columns crossed —
// grabbing the middle of a multi-day bar shifts it by
// what the finger travelled, not to where it landed.
target = MoveTarget.Day(
drop.event.spanFirstDay(zone).plus(delta.toInt(), DateTimeUnit.DAY),
),
),
)
}
}
},
onCancel = { controller?.cancel() },
)
/**
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
* day cells' geometry, set apart by the secondaryContainer tint (matching the
@@ -2057,6 +2236,9 @@ private fun MonthBar(
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels.
val lifted = LocalMonthDrag.current?.liftedInstanceId == event.instanceId
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -2065,9 +2247,13 @@ private fun MonthBar(
)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier)
.background(fill, shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },
.semantics {
contentDescription = title
if (moveAction != null) customActions = listOf(moveAction)
},
contentAlignment = Alignment.CenterStart,
) {
Text(

View File

@@ -67,6 +67,26 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
return byLane.filterNotNull()
}
/**
* The event drawn in lane [lane] of column [col], or null for an empty slot.
*
* Mirrors how the paged grid seats a week: spanning bars keep the lane the row
* layout gave them, and the day's timed pills fill the slots those bars leave.
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
* this point", so an empty lane above a seated bar has to stay empty (#68).
*/
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val free = (0 until laneCap).filter { it !in occupied }
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
}
/**
* The events on [day] that [laneEvents] had no lane left for — its exact
* complement, in the same bars-then-pills order. Returned as events rather than

View File

@@ -62,9 +62,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -84,6 +86,18 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
@@ -128,6 +142,8 @@ private val GUTTER_WIDTH = 48.dp
private val GUTTER_CONTENT_START_INSET = 8.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Gap between day columns; part of the column pitch a drag maps positions through. */
private val COLUMN_GAP = 2.dp
/** Total all-day strip height for a week (0 when there are no all-day events). */
private fun WeekUiState.Success.allDayStripHeight(): Dp {
@@ -331,32 +347,52 @@ private fun WeekContent(
// gestures coexist without fighting.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
AnimatedContent(
targetState = state,
modifier = modifier.then(swipeModifier),
contentKey = { s ->
// Above the AnimatedContent on purpose: a page change mid-drag would strand
// the floating block inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
Box(modifier = modifier) {
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is WeekUiState.Success -> "success-${s.weekStart}"
is WeekUiState.Failure -> "failure-${s.reason}"
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition",
) { s ->
when (s) {
is WeekUiState.Success -> "success-${s.weekStart}"
is WeekUiState.Failure -> "failure-${s.reason}"
WeekUiState.Loading -> "loading"
WeekUiState.Loading -> WeekLoading()
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
)
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition",
) { s ->
when (s) {
WeekUiState.Loading -> WeekLoading()
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
)
}
TimelineDragOverlay(dragController)
}
}
@@ -366,9 +402,11 @@ private fun WeekSuccess(
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -385,8 +423,10 @@ private fun WeekSuccess(
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
@@ -622,13 +662,16 @@ private fun AllDayBar(
private fun Timeline(
state: WeekUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -680,13 +723,28 @@ private fun Timeline(
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
horizontalArrangement = Arrangement.spacedBy(2.dp),
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset — a drag maps
// through it without reading the scroll state.
.onGloballyPositioned { coords ->
val gap = with(density) { COLUMN_GAP.toPx() }
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
}
},
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
) {
state.days.forEach { day ->
DayColumnCard(
@@ -695,8 +753,10 @@ private fun Timeline(
date = day,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
@@ -715,8 +775,10 @@ private fun DayColumnCard(
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
@@ -758,7 +820,10 @@ private fun DayColumnCard(
dark = dark,
height = height,
width = laneWidth,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
@@ -780,7 +845,10 @@ private fun EventBlock(
dark: Boolean,
height: Dp,
width: Dp,
date: LocalDate,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -823,12 +891,33 @@ private fun EventBlock(
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge is
// midnight, not the event's start, so dragging it would invent a time.
val dragModifier = rememberEventDragSource(
enabled = moveAction != null && block.beginsOn(date, zone),
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = dragController.liftedInstanceId == block.event.instanceId
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels.
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier)
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {

View File

@@ -65,6 +65,20 @@
<string name="event_edit_recurring_title">Edit recurring event</string>
<string name="event_delete_failed">Couldn\'t delete the event</string>
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
<!-- Drag an event to another time or day (#68) -->
<string name="event_move_action">Move…</string>
<string name="event_move_recurring_title">Move recurring event</string>
<string name="event_move_occurrence_only">This series picks its days in a way that can\'t be recalculated from one moved event, so only this event can move.</string>
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
<string name="event_move_done">Moved to %1$s</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>
<string name="event_move_failed">Couldn\'t move the event</string>
<string name="event_move_write_denied">Calendula needs write access to move events</string>
<string name="event_move_gone">That event no longer exists</string>
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_ok">OK</string>

View File

@@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -285,6 +286,66 @@ class EventWriteMapperTest {
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
}
@Test
fun `a weekday move must carry the rule with the anchor, or the series stays put`() {
// The anchor moves by the same shift as the occurrence, so a Monday
// series lands on a Wednesday — while RRULE is written verbatim. Without
// realignRecurrence the rule would still say Monday and nothing moves.
val series = instantAt("2026-01-05T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
)
val values = update(original, moved, series)
val anchor = java.time.Instant.ofEpochMilli(values[CalendarContract.Events.DTSTART] as Long)
.atZone(java.time.ZoneId.of("Europe/Berlin"))
assertThat(anchor.dayOfWeek).isEqualTo(java.time.DayOfWeek.WEDNESDAY)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `a recurring move keeps the series' length across a DST boundary`() {
// The occurrence being dragged is in CET; the series anchor is in CEST.
val series = instantAt("2026-07-15T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
.isEqualTo("P5400S")
}
@Test
fun `shifting a one-off event and back again restores the original values`() {
val original = form()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
)
val forward = update(original, moved)
val back = update(moved, original)
assertThat(back[CalendarContract.Events.DTSTART])
.isEqualTo(original.toWriteTimes(berlin).dtStartMillis)
assertThat(back[CalendarContract.Events.DTEND])
.isEqualTo(original.toWriteTimes(berlin).dtEndMillis)
assertThat(forward[CalendarContract.Events.DTSTART])
.isNotEqualTo(back[CalendarContract.Events.DTSTART])
}
@Test
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")

View File

@@ -94,6 +94,27 @@ class CalendarRowStateTest {
assertThat(calendar.isEventTarget).isFalse()
}
@Test
fun `a managed calendar's events are editable but never movable`() {
// Their date is owned by the contacts sync, so a drag would be undone.
val managed = cal().copy(isManaged = true)
assertThat(managed.allowsEventMove).isFalse()
}
@Test
fun `a read-only calendar's events are not movable`() {
assertThat(cal().copy(canModifyContents = false).allowsEventMove).isFalse()
}
@Test
fun `a switched-off calendar's events stay movable, unlike a new-event target`() {
// Nothing renders them, so the question is moot — but the predicate is
// deliberately not isEventTarget, which would also exclude them.
val hidden = cal().copy(isVisibleInSystem = false)
assertThat(hidden.isEventTarget).isFalse()
assertThat(hidden.allowsEventMove).isTrue()
}
@Test
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
val ordered = listOf(

View File

@@ -0,0 +1,137 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
class EventShiftTest {
private val berlin = TimeZone.of("Europe/Berlin")
private val newYork = TimeZone.of("America/New_York")
private fun form(
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
timezone: String? = null,
isAllDay: Boolean = false,
) = EventForm(
calendarId = 1L,
title = "Standup",
isAllDay = isAllDay,
start = start,
end = end,
timezone = timezone,
)
@Test
fun `shifting to a new start keeps the length`() {
val original = form()
val target = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
}
@Test
fun `a pinned event is re-derived in its own zone, not the device's`() {
// 09:00 in New York, opened on a Berlin device.
val original = form(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
timezone = "America/New_York",
)
// The grid says "15:30 Berlin", which is 09:30 in New York.
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
assertThat(moved.timezone).isEqualTo("America/New_York")
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
}
@Test
fun `the instant duration survives a shift across spring forward`() {
// 2026-03-29 02:00 CET is when Berlin skips to 03:00.
val original = form(
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(23, 0)),
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
)
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
// Two real hours from 01:00 CET lands at 04:00 CEST, not 03:00: the wall
// clock stretches because the hour in between does not exist.
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
}
@Test
fun `a drop into the spring-forward gap resolves forward by the missing hour`() {
val original = form()
// 02:30 does not exist on 2026-03-29 in Berlin.
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
@Test
fun `a zero-distance drop leaves the form untouched`() {
val original = form()
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
}
@Test
fun `an unparseable pinned zone falls back to the device, like the write path`() {
val original = form(timezone = "Mars/Olympus")
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)).toInstant(newYork)
val moved = original.shiftedTo(target, newYork)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
}
@Test
fun `an all-day event ignores shiftedTo and moves by whole days instead`() {
val allDay = form(isAllDay = true)
val target = LocalDateTime(LocalDate(2026, 7, 1), LocalTime(3, 0)).toInstant(berlin)
assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay)
val moved = allDay.shiftedByDays(3)
assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14))
assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14))
// The placeholder times exist only for a switch back to timed; untouched.
assertThat(moved.start.time).isEqualTo(allDay.start.time)
}
@Test
fun `a multi-day event keeps its span when shifted by days`() {
val multiDay = form(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(22, 0)),
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(2, 0)),
)
val moved = multiDay.shiftedByDays(-2)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
}
@Test
fun `shifting by no days is a no-op`() {
val original = form()
assertThat(original.shiftedByDays(0)).isEqualTo(original)
}
}

View File

@@ -0,0 +1,90 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class RecurrenceRealignTest {
private val monday = LocalDate(2026, 6, 8)
private val wednesday = LocalDate(2026, 6, 10)
@Test
fun `a weekly BYDAY rule follows the occurrence to its new weekday`() {
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
.isEqualTo("FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `unrelated parts survive the rewrite`() {
assertThat(
realignRecurrence("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;COUNT=10", monday, wednesday),
).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE;COUNT=10")
}
@Test
fun `a monthly BYMONTHDAY rule follows the day of the month`() {
assertThat(
realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday),
).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=10")
}
@Test
fun `a yearly rule realigns both month and day`() {
assertThat(
realignRecurrence(
"FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8",
monday,
LocalDate(2026, 7, 20),
),
).isEqualTo("FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=20")
}
@Test
fun `a rule with no day-selecting part needs no rewrite`() {
assertThat(realignRecurrence("FREQ=DAILY;INTERVAL=3", monday, wednesday))
.isEqualTo("FREQ=DAILY;INTERVAL=3")
assertThat(realignRecurrence("FREQ=WEEKLY", monday, wednesday)).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a rule the move does not disturb comes back verbatim`() {
val rule = "FREQ=WEEKLY;BYDAY=MO,WE,FR"
assertThat(realignRecurrence(rule, monday, monday)).isEqualTo(rule)
}
@Test
fun `a multi-day BYDAY cannot be resolved from one moved occurrence`() {
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO,WE", monday, wednesday)).isNull()
}
@Test
fun `an ordinal BYDAY cannot be resolved`() {
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=2MO", monday, wednesday)).isNull()
}
@Test
fun `a BYDAY that does not name the occurrence's own weekday is refused`() {
// The rule and DTSTART already disagree; guessing would make it worse.
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=TU", monday, wednesday)).isNull()
}
@Test
fun `parts we cannot reason about are refused rather than guessed`() {
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1", monday, wednesday))
.isNull()
assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull()
}
@Test
fun `a leading RRULE prefix is preserved`() {
assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
.isEqualTo("RRULE:FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `a malformed rule is refused`() {
assertThat(realignRecurrence("FREQ=WEEKLY;GARBAGE", monday, wednesday)).isNull()
assertThat(realignRecurrence("", monday, wednesday)).isNull()
}
}

View File

@@ -0,0 +1,391 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
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 de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
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
/**
* The drop pipeline: which repository call a scope maps to, when a recurring drop
* has to ask first, and every case that must refuse to write.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RescheduleViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
// Monday 2026-06-08, midday UTC — midday so the device zone can't move the
// weekday out from under the BYDAY assertions.
private val monday = LocalDate(2026, 6, 8)
private val wednesday = LocalDate(2026, 6, 10)
private val beginMillis =
LocalDateTime(monday, LocalTime(12, 0)).toInstant(TimeZone.UTC).toEpochMilliseconds()
private val endMillis = beginMillis + 3_600_000L
private fun cal(
id: Long,
canModify: Boolean = true,
managed: Boolean = false,
): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
isManaged = managed,
)
private fun detail(
rrule: String? = null,
isAllDay: Boolean = false,
isException: Boolean = false,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
isAllDay = isAllDay, color = 0xFF000000.toInt(), location = null,
),
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
isException = isException,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
val prefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("move_prefs.preferences_pb").toFile() },
),
)
val settings = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("move_settings.preferences_pb").toFile() },
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
return RescheduleViewModel(repo, dispatcher)
}
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
launch { vm.movableCalendarIds.collect {} }
/** A drop two days later, expressed the way the month grid expresses one. */
private fun toWednesday() = MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Day(wednesday),
)
/** A drop an hour later, expressed the way a timeline drag expresses one. */
private fun oneHourLater() = MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
)
@Test
fun `a one-off drop writes straight through with no scope prompt`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).hasSize(1)
val (id, original, updated) = fake.updatedEvents.single()
assertThat(id).isEqualTo(42L)
assertThat(updated.start).isNotEqualTo(original.start)
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
}
@Test
fun `a recurring drop parks for the scope instead of writing`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@Test
fun `each scope maps to its own repository call`(@TempDir tempDir: Path) = runTest(dispatcher) {
val occurrence = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
viewModel(tempDir.resolve("a").also { it.toFile().mkdirs() }, occurrence).run {
move(oneHourLater())
moveWithScope(RecurringWriteScope.ThisEvent)
}
advanceUntilIdle()
assertThat(occurrence.updatedOccurrences).hasSize(1)
assertThat(occurrence.updatedOccurrences.single().second).isEqualTo(beginMillis)
val following = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
viewModel(tempDir.resolve("b").also { it.toFile().mkdirs() }, following).run {
move(oneHourLater())
moveWithScope(RecurringWriteScope.ThisAndFollowing)
}
advanceUntilIdle()
assertThat(following.updatedFromOccurrences).hasSize(1)
val series = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
viewModel(tempDir.resolve("c").also { it.toFile().mkdirs() }, series).run {
move(oneHourLater())
moveWithScope(RecurringWriteScope.AllEvents)
}
advanceUntilIdle()
assertThat(series.updatedEvents).hasSize(1)
}
@Test
fun `a weekly BYDAY rule is realigned when the whole series moves weekday`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
// Without this the anchor becomes a Wednesday while the rule still says
// Monday, and the series does not move at all.
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `a rule that cannot be realigned offers only the single occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
}
@Test
fun `a same-time drop within one day still needs no realignment`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
// The weekday is unchanged, so the ambiguous BYDAY is not in the way.
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
}
@Test
fun `an exception row is written as a plain event, never as a nested exception`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
// A sync adapter that left the series' rule on the override row.
eventDetailResult = { detail(rrule = "FREQ=WEEKLY", isException = true) }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).hasSize(1)
assertThat(fake.updatedOccurrences).isEmpty()
}
@Test
fun `a drop past the series' own UNTIL is refused, not written`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
// Midday UTC, so the device zone can't push the UNTIL date onto the
// day the event is being dragged to.
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd)
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `a zero-distance drop writes nothing and says nothing`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
),
)
advanceUntilIdle()
assertThat(fake.updatedEvents).isEmpty()
assertThat(vm.outcome.value).isNull()
}
@Test
fun `a vanished event reports itself as gone`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
}
@Test
fun `a revoked write permission is reported apart from a plain failure`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail() }
writeError = SecurityException("revoked")
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
}
@Test
fun `undo writes the move back the other way`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo
assertThat(undo).isNotNull()
vm.undo(undo!!)
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(2)
val forward = fake.updatedEvents[0]
val back = fake.updatedEvents[1]
assertThat(back.second).isEqualTo(forward.third)
assertThat(back.third).isEqualTo(forward.second)
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
}
@Test
fun `an occurrence write offers no undo, since shifting back cannot restore it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
vm.moveWithScope(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
}
@Test
fun `only writable, unmanaged calendars can be dragged from`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(
cal(1L),
cal(2L, canModify = false),
cal(3L, managed = true),
)
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
advanceUntilIdle()
assertThat(vm.movableCalendarIds.value).containsExactly(1L)
job.cancel()
}
@Test
fun `cancelling the scope dialog writes nothing`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
}

View File

@@ -108,6 +108,37 @@ on-device):
UTC, so zones ahead of UTC can't leak an extra occurrence.
- All-day events are normalised to UTC midnights with an exclusive end.
### Drag to reschedule
Dropping an event on another slot (#68) is not a second write path: the drop
loads the event, prefills the **same** `toEditForm` the edit screen uses, shifts
it (`EventShift.kt`), and dispatches through the same three repository calls a
save does — so recurring writes, reminder reconciliation and attendee
preservation behave identically. The full form is carried through, never a
stripped one: `updateOccurrence` reconciles reminders *and* attendees onto the
new exception row, and a partial form would wipe them.
Two things the drag has to get right that the edit screen sidesteps:
- **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule
verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence
onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday
rule and the series does not move. `realignRecurrence` re-derives `BYDAY` /
`BYMONTHDAY` / `BYMONTH` from the new date, and returns **null** for rules one
moved occurrence cannot resolve (`BYDAY=MO,WE`, `2TH`, `BYSETPOS`) — the drop
then offers only *this event*, whose exception row carries no rule at all. The
same staleness is reachable from the edit screen; wiring it there too is a
separate change.
- **The eligibility gate is load-bearing.** Nothing below the UI refuses a
write, so `CalendarSource.allowsEventMove` is what keeps read-only and
contact-managed events from being dragged. It is deliberately *not*
`isEventTarget`: a managed event is editable (reminders, notes) yet must never
move, while a switched-off calendar renders nothing to grab anyway.
`EventForm.problems()` runs before the write, so a drop that would push a series
past its own `UNTIL` — which makes the provider generate zero occurrences and the
event vanish — is refused rather than written.
### Event time zones
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
@@ -144,6 +175,19 @@ changes to untouched fields survive either way. Fields the form cannot
write (attendees, status, reminder methods) are excluded so sync noise
can't fake a conflict.
A **dropped** event gets no conflict dialog, deliberately. Its blast radius is
already bounded by the same dirty check — only `ALL_DAY`, `EVENT_TIMEZONE`,
`DTSTART` and `DTEND`/`DURATION`/`RRULE` are written — so a concurrent remote
edit to the title, notes or guests survives untouched. What a drop *can* clobber
is a concurrent remote **time** change, and parking a one-gesture action behind a
modal would cost more than that case is worth; the undo in the confirmation
snackbar is the answer instead. Undo restores semantics, not the row's byte
shape (`DURATION` normalises to `P<n>S`/`P<n>D`, `EVENT_TIMEZONE` is stamped
concrete), and it is offered only where the inverse is one symmetric write — a
one-off event or a whole-series shift. *This event* leaves an exception row
behind and *this and following* splits the series; neither is undone by shifting
back, so both get a plain confirmation.
## Reminder delivery
Calendula plans and fires its own reminders. It reads the offsets in