Compare commits
14 Commits
v2.18.0
...
5b4c32aff9
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b4c32aff9 | |||
| e13b631e10 | |||
| 74780cc212 | |||
| 1285bbbf47 | |||
| 4412038caa | |||
| 1c9a4f7f50 | |||
| b7e624877d | |||
| 7a86014461 | |||
| 5eb7d76f8a | |||
| 19b86936e6 | |||
| 0bcbd2751c | |||
| cf0540fce4 | |||
| aa24de443d | |||
|
|
5764bd889b |
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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.
|
||||
* A multi-day event keeps its span rather than collapsing to a single day.
|
||||
*
|
||||
* An all-day event is pure date arithmetic — both ends move, and the placeholder
|
||||
* times ride along untouched. A timed one re-derives its end from the preserved
|
||||
* **instant** duration, for the same reason [shiftedTo] does: the length travels
|
||||
* to a recurring row as `DURATION`, so a move onto a DST changeover that kept
|
||||
* wall clock would silently rewrite the whole series' length.
|
||||
*/
|
||||
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
if (days == 0) return this
|
||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||
if (isAllDay) {
|
||||
return copy(
|
||||
start = newStart,
|
||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* [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. `BYDAY` is therefore
|
||||
* re-derived from [newStart].
|
||||
*
|
||||
* **Only weekly `BYDAY` is realigned, and only for a whole-day move.** The rule
|
||||
* has to agree with the series *anchor*, which is not the occurrence being
|
||||
* dragged: the anchor moves by the same wall-clock shift, and only weekday
|
||||
* arithmetic survives that unchanged, because it is uniform mod 7 and every
|
||||
* anchor time-of-day crosses the same number of midnights. Day-of-month does not
|
||||
* — `BYMONTHDAY=28` on a January anchor dragged from Feb 28 to Mar 1 gives an
|
||||
* anchor of Jan 29 under a `BYMONTHDAY=1` rule, which is not an instance of its
|
||||
* own rule and materialises a phantom occurrence on any client that trusts
|
||||
* DTSTART. Those rules return null instead. The caller must also refuse when the
|
||||
* shift is not a whole number of days, for the same reason (see
|
||||
* `RescheduleViewModel`).
|
||||
*
|
||||
* Null also covers rules one moved occurrence cannot resolve at all
|
||||
* (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …) — dropping the extra days
|
||||
* would delete occurrences. The accepted parts are deliberately a subset of what
|
||||
* [parseSimpleRecurrence] understands, so anything realignable is also a rule
|
||||
* [problems] can check the `UNTIL` of.
|
||||
*/
|
||||
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
|
||||
var weekly = false
|
||||
val rebuilt = parts.map { part ->
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
val key = part.substring(0, eq).uppercase()
|
||||
val value = part.substring(eq + 1).trim()
|
||||
when (key) {
|
||||
"FREQ" -> {
|
||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||
part
|
||||
}
|
||||
"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)}"
|
||||
}
|
||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
// BYDAY is only simple on a weekly rule (matching parseSimpleRecurrence);
|
||||
// "every Monday of the month" is a shape this has not been reasoned about.
|
||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||
return prefix + rebuilt.joinToString(";")
|
||||
}
|
||||
@@ -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,29 @@ 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,
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
@@ -311,6 +339,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 +396,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.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/**
|
||||
* A timed block's own time label, crossfaded rather than replaced. The block
|
||||
* keeps its identity across a move and slides to the new slot; the label is the
|
||||
* one thing on it that would otherwise change in a single frame.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
label = "block-time",
|
||||
modifier = modifier,
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a timed block sits in its column, after tweening. */
|
||||
@Immutable
|
||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
|
||||
/**
|
||||
* A timed block's placement, tweened rather than jumped. Every bound a block
|
||||
* has changes for a reason the user just caused — a drop landing at a new time,
|
||||
* an undo putting it back, a neighbour arriving and halving both lanes — and all
|
||||
* of them read better as motion than as a new layout appearing.
|
||||
*
|
||||
* Continuity comes from the caller keying each block by identity; a block
|
||||
* composed for the first time starts *at* its target, so nothing flies in from
|
||||
* the corner on the first frame.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
}
|
||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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 {
|
||||
// Once the block is lifted the gesture is ours, so it is
|
||||
// driven on the initial pass and consumed there. That pass
|
||||
// runs parent → child, which gets both halves right: an
|
||||
// ancestor that outranks us (the pinch, which claims the
|
||||
// moment a second finger lands anywhere in the timeline) has
|
||||
// already consumed by the time we look, and everything below
|
||||
// us — the month grid's full-bleed tap layer, which is a
|
||||
// descendant and would otherwise open the day on lift — sees
|
||||
// ours. Consumption persists across passes, so the scroll and
|
||||
// the page swipe stand down on main as well.
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (change.isConsumed) break
|
||||
if (!change.pressed) {
|
||||
change.consume()
|
||||
dropped = true
|
||||
break
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Locale
|
||||
|
||||
/** How long the confirmation chip stays up, matching a short snackbar. */
|
||||
private const val CHIP_MILLIS = 4_000L
|
||||
|
||||
/**
|
||||
* How long an *undone* move stays up. Shorter than everything else the chip
|
||||
* says: it offers nothing to act on, and it confirms a change the user has just
|
||||
* asked for and can see on the grid behind it, so the full dwell is only the
|
||||
* chip outstaying what it had to say.
|
||||
*/
|
||||
private const val UNDONE_CHIP_MILLIS = 1_600L
|
||||
|
||||
/** What the chip currently reads, kept past the outcome it was built from. */
|
||||
private data class ChipContent(val message: String, val undo: MoveUndo?)
|
||||
|
||||
/** The FAB's own band at the bottom end, which the chip must not run into. */
|
||||
private val FAB_BAND = 88.dp
|
||||
|
||||
/**
|
||||
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
||||
* recurring-scope prompt, and the confirmation chip carrying Undo.
|
||||
*
|
||||
* None of the four calendar screens sets a `snackbarHost` on its Scaffold, so
|
||||
* this hosts its own confirmation — a pill on the FAB's own band at the bottom
|
||||
* start rather than a full-width bar, so the calendar it confirms a change to
|
||||
* stays visible behind it.
|
||||
*/
|
||||
@Composable
|
||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
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 (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)
|
||||
}
|
||||
|
||||
// Held past the outcome being consumed so the chip has something to draw
|
||||
// while it springs back out. Updated *in composition* rather than from an
|
||||
// effect: an effect lands a frame late, so the chip would open carrying the
|
||||
// previous message and grow into this one while it was still animating in.
|
||||
val shown = remember { mutableStateOf(ChipContent("", null)) }
|
||||
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
|
||||
shown.value = ChipContent(message, moved?.undo)
|
||||
}
|
||||
val content = shown.value
|
||||
LaunchedEffect(outcome) {
|
||||
if (outcome == null) return@LaunchedEffect
|
||||
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||
viewModel.consumeOutcome()
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val chipMaxWidth = maxWidth - FAB_BAND
|
||||
// A FAB-height band anchored at the bottom start with the FAB's own
|
||||
// margin; centring the chip in it lines it up beside the bottom-end FAB
|
||||
// at exactly its height, rather than sitting a touch above it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.navigationBarsPadding()
|
||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
SnackChip(
|
||||
visible = outcome != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo)
|
||||
.takeIf { content.undo != null },
|
||||
onAction = content.undo?.let { undo -> { viewModel.undo(undo) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "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))
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* 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,
|
||||
/**
|
||||
* True while a dropped event is being written — including the time its scope
|
||||
* dialog is up. A flow rather than a value so this scope stays the same
|
||||
* object across a move: it is a composition local every visible block reads,
|
||||
* and replacing it would recompose all of them twice per drop.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean>,
|
||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<Int>,
|
||||
/** 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 }
|
||||
|
||||
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||
|
||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Whether a dropped event is still being written — false wherever moving is off.
|
||||
* The drag overlays hold a landed block on its target for this window.
|
||||
*/
|
||||
@Composable
|
||||
fun moveInFlight(): Boolean {
|
||||
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||
* this composable came on screen — a view switched to *after* an undo has
|
||||
* nothing left to carry back.
|
||||
*/
|
||||
@Composable
|
||||
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||
val tick by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableIntStateOf(tick) }
|
||||
LaunchedEffect(tick) {
|
||||
if (tick == seen) return@LaunchedEffect
|
||||
seen = tick
|
||||
onUndo()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opacity the source block keeps while its floating copy travels. */
|
||||
const val GHOST_ALPHA: Float = 0.3f
|
||||
|
||||
/**
|
||||
* Opacity for a block whose copy is in flight: ghosted from the lift until the
|
||||
* copy is handed back, then animated up rather than switched. The ghost keeps
|
||||
* its place through the write and *travels* to the new slot when the grid
|
||||
* re-reads it, arriving under the copy as that fades — so what the eye follows
|
||||
* is one block moving, not one vanishing and another appearing.
|
||||
*/
|
||||
@Composable
|
||||
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
|
||||
targetValue = if (lifted) GHOST_ALPHA else 1f,
|
||||
label = "ghost-alpha",
|
||||
).value
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||
val GUTTER_WIDTH = 48.dp
|
||||
|
||||
/**
|
||||
* Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
|
||||
* matching the icon button's centre.
|
||||
*/
|
||||
val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
|
||||
private val BADGE_HEIGHT = 20.dp
|
||||
|
||||
/** How far the fixed hour labels recede while a block is being dragged. */
|
||||
private const val DIMMED_HOUR_ALPHA = 0.3f
|
||||
|
||||
/**
|
||||
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
||||
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
||||
* the hour labels and floats a badge with the drag's current start time at the
|
||||
* row the block would land on.
|
||||
*/
|
||||
@Composable
|
||||
fun HourGutter(
|
||||
scrollState: ScrollState,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
// Derived, not read directly: the drag is rewritten every frame, while its
|
||||
// snapped start only changes once per slot — which is all the gutter shows.
|
||||
val dragStartMin by remember(dragController) {
|
||||
derivedStateOf { dragController.drag?.startMin }
|
||||
}
|
||||
val hourAlpha by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||
label = "hourLabelAlpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Column {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = hourAlpha),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragStartMin?.let { startMin ->
|
||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = top)
|
||||
.height(BADGE_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(horizontal = 4.dp)
|
||||
// The dragged block behind it already carries this time; a
|
||||
// second copy would only duplicate the announcement.
|
||||
.clearAndSetSemantics { },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = formatGutterTime(startMin, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
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.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
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.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
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 whole-day shift, keeping the time of day — a month-grid or all-day drag.
|
||||
* A delta rather than a target date on purpose: the grid already knows how
|
||||
* many columns the finger crossed, and re-deriving that from a date would
|
||||
* mean the screen and this view model each resolving the event's first day
|
||||
* in their own zone, which can disagree by a day.
|
||||
*/
|
||||
data class ByDays(val days: Int) : 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
|
||||
|
||||
private val _inFlight = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True from the moment a drop is accepted until its write settles — the
|
||||
* window the dropped block holds its landing position for, rather than
|
||||
* snapping back to where it came from until the grid re-reads it.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||
|
||||
private val _undoStarted = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Ticks the moment an undo write begins — before the provider has anything to
|
||||
* re-read. An undo moves an event exactly as a drop does, so the view that
|
||||
* drew the drop takes this as its cue to carry the chip back rather than let
|
||||
* it reappear on the old day. A counter rather than the undo itself: what a
|
||||
* view needs is the *timing*, and it already knows what it moved.
|
||||
*/
|
||||
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||
* of the same recurring event landing inside that window would each compute
|
||||
* their shift from the same pre-move occurrence, and the data layer applies
|
||||
* both to the re-read anchor — so the shifts would compound.
|
||||
*/
|
||||
private var busy = false
|
||||
set(value) {
|
||||
field = value
|
||||
_inFlight.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||
val newAnchorDate: LocalDate,
|
||||
)
|
||||
|
||||
fun move(request: MoveRequest) {
|
||||
if (busy || _scopePrompt.value != null) return
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
val prepared = prepare(request)
|
||||
if (prepared == null) {
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
if (!prepared.isRecurring) {
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer the scope dialog. */
|
||||
fun moveWithScope(scope: RecurringWriteScope) {
|
||||
val prepared = pending ?: return
|
||||
// Belt and braces against the dialog ever offering a scope the rule
|
||||
// can't carry: writing it would leave anchor and rule disagreeing.
|
||||
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
write(prepared, scope)
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the scope dialog without writing. */
|
||||
fun cancelScope() {
|
||||
pending = null
|
||||
busy = false
|
||||
_scopePrompt.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a completed move back where it came from. The outcome deliberately
|
||||
* stands until the inverse write reports back: clearing it first would drop
|
||||
* the confirmation chip and open a second one a moment later, rather than
|
||||
* letting the one chip change what it says.
|
||||
*/
|
||||
fun undo(undo: MoveUndo) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
_undoStarted.value += 1
|
||||
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
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.ByDays -> original.shiftedByDays(target.days, zone)
|
||||
}
|
||||
// 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
|
||||
|
||||
// The UNTIL check is deferred to the write: how far the move reaches
|
||||
// decides which date has to clear it, and "only this event" writes an
|
||||
// exception row that no UNTIL constrains at all.
|
||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).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
|
||||
// The series anchor moves by the same *wall-clock* shift as the dragged
|
||||
// occurrence, so only a whole-day shift moves it by a predictable number
|
||||
// of days — a drag that also changes the time of day would carry some
|
||||
// anchor times of day across an extra midnight and leave the rule naming
|
||||
// the wrong weekday. All-day forms shift as bare dates, so they always
|
||||
// qualify.
|
||||
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
|
||||
val realigned = if (isRecurring && movedDay) {
|
||||
if (wholeDayShift) {
|
||||
realignRecurrence(
|
||||
requireNotNull(original.rrule),
|
||||
original.start.date,
|
||||
shifted.start.date,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} 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,
|
||||
// Where the series row's own DTSTART lands, given the anchor moves by
|
||||
// the same shift. Only meaningful under [wholeDayShift], which is the
|
||||
// only case a wider-than-one-occurrence write is offered in.
|
||||
newAnchorDate = anchorDate(detail, original, zone)
|
||||
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||
val request = prepared.request
|
||||
if (endsBeforeItStarts(prepared, scope)) {
|
||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||
return
|
||||
}
|
||||
_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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this write would leave a rule whose `UNTIL` precedes the first day
|
||||
* it now applies to — the provider then generates nothing and the event
|
||||
* silently disappears from every view.
|
||||
*
|
||||
* Which date has to clear `UNTIL` depends on how far the write reaches: a
|
||||
* whole-series move carries the series *anchor*, a split starts a new series
|
||||
* at the moved occurrence, and a single occurrence becomes an exception row
|
||||
* that no `UNTIL` constrains. Testing the occurrence in every case would
|
||||
* refuse the perfectly ordinary drag of a bounded series' last occurrence.
|
||||
*/
|
||||
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
|
||||
val rule = prepared.updated.rrule ?: return false
|
||||
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
|
||||
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
|
||||
prepared.newAnchorDate
|
||||
} else {
|
||||
prepared.updated.start.date
|
||||
}
|
||||
return end.date < firstDay
|
||||
}
|
||||
|
||||
/**
|
||||
* The series row's own start date — for a recurring master, `EventDetail`
|
||||
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
|
||||
* same anchoring the write path uses: UTC for an all-day series.
|
||||
*/
|
||||
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
|
||||
detail.instance.start
|
||||
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
|
||||
.date
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
||||
private const val PATTERN_24 = "HH:mm"
|
||||
private const val PATTERN_12 = "h:mm a"
|
||||
private const val HOUR_PATTERN_12 = "h a"
|
||||
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
|
||||
|
||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||
@@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The time shown in the timeline gutter while a block is dragged: 24h →
|
||||
* "09:15", 12h → "9:15". The meridiem is dropped on purpose — the hour labels
|
||||
* around it already carry it, and the gutter is too narrow to hold it.
|
||||
*/
|
||||
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
|
||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
|
||||
return LocalTime.of(clamped / 60, clamped % 60)
|
||||
.format(DateTimeFormatter.ofPattern(pattern, locale))
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||
* the prior look); 12h → "1 PM".
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
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.absoluteOffset
|
||||
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.semantics.clearAndSetSemantics
|
||||
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.coroutines.delay
|
||||
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()
|
||||
|
||||
/**
|
||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||
* never mirrored, but the grid is, so the leftmost column is the *last* day
|
||||
* in Arabic — the mapping has to flip with it.
|
||||
*/
|
||||
var isRtl: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* A dropped block, held at the slot it landed on while the write runs. The
|
||||
* grid behind it still shows the old time until the provider notifies and
|
||||
* the query re-reads, so releasing the copy at drop time would snap the
|
||||
* event back to where it came from for the length of the write.
|
||||
*/
|
||||
var settling: 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. Stays
|
||||
* set through [settling], so the source never reappears under the copy.
|
||||
*/
|
||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Where the settled drop came from, and which event row it belongs to. The
|
||||
* instance id alone can't identify the source block for the length of the
|
||||
* write: the provider regenerates `Instances` rows, so a re-read that still
|
||||
* carries the *old* time can arrive under a new instance id — and a source
|
||||
* matched by instance id would stop being the ghost and flash back to full
|
||||
* opacity in the slot the event is about to leave.
|
||||
*/
|
||||
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
||||
|
||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||
* The copy may only be handed back once this is true: releasing on a timer
|
||||
* puts the source ghost back at full opacity in its *old* slot for whatever
|
||||
* is left of the re-read — a flicker of the event where it no longer is.
|
||||
*/
|
||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var source: TimedBlock? = null
|
||||
private var grab = Offset.Zero
|
||||
private var pointer = Offset.Zero
|
||||
|
||||
/**
|
||||
* The slot the block already occupied when it was picked up. A long press
|
||||
* that never moves must write nothing — and because the target snaps to the
|
||||
* grid, "nothing moved" is not the same as "the start is unchanged": an
|
||||
* event at 09:07 resolves to 09:00 the instant it lifts.
|
||||
*/
|
||||
private var originSlot: Pair<LocalDate, Int>? = null
|
||||
|
||||
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||
source = block
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
pointer = pointerInRoot
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
source = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
originSlot = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [block] is the one whose copy is in flight, and so must stay a
|
||||
* ghost. While the finger holds it that is the instance it picked up; once
|
||||
* dropped it is also whatever now sits in the slot it left, however the
|
||||
* provider has renumbered it in the meantime.
|
||||
*/
|
||||
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
||||
if (liftedInstanceId == null) return false
|
||||
if (block.event.instanceId == liftedInstanceId) return true
|
||||
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
||||
return block.event.eventId == eventId &&
|
||||
date == originDate &&
|
||||
block.startMin == originMin
|
||||
}
|
||||
|
||||
/**
|
||||
* What a day column now holds, so a settled drop can tell when the grid has
|
||||
* caught up with it. Matched on the landing slot plus either the event row
|
||||
* or its title: a single-occurrence move writes an exception row with a new
|
||||
* `eventId`, which nothing else about the drop can predict.
|
||||
*/
|
||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||
val landed = settling ?: return
|
||||
if (settledOnGrid || landed.date != date) return
|
||||
settledOnGrid = blocks.any { block ->
|
||||
block.startMin == landed.startMin &&
|
||||
(
|
||||
block.event.eventId == landed.event.eventId ||
|
||||
block.event.title == landed.event.title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the drag, handing back where it landed — null when it never resolved,
|
||||
* or when it landed back on the slot it started from. A real drop keeps its
|
||||
* copy on the target as [settling] until [release].
|
||||
*/
|
||||
fun finish(): TimelineDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
settling = landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||
* the copy is still on screen dissolving. Un-ghosting only at [release]
|
||||
* would leave the block under the fading copy dim, and brighten it once the
|
||||
* copy was gone: a dip the eye reads as the event flickering.
|
||||
*/
|
||||
fun handOver() {
|
||||
liftedInstanceId = null
|
||||
settledOrigin = null
|
||||
}
|
||||
|
||||
/** Drop the copy, once it has faded into the grid's own block. */
|
||||
fun release() {
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
handOver()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// The column the finger is over on screen, and the day that column shows.
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
drag = TimelineDrag(
|
||||
event = block.event,
|
||||
date = days[dayIndex],
|
||||
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)
|
||||
|
||||
/**
|
||||
* A beat after the grid draws the drop, so its own block is under way before the
|
||||
* copy starts dissolving.
|
||||
*/
|
||||
const val SETTLE_GRACE_MILLIS: Long = 60L
|
||||
|
||||
/**
|
||||
* How long to wait for a grid that never confirms the drop — the event landed on
|
||||
* a day this timeline doesn't show, or the write failed and nothing changed. The
|
||||
* copy has to go either way.
|
||||
*/
|
||||
private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||
|
||||
/**
|
||||
* The hand-over: the copy dissolves over this while the grid's own block slides
|
||||
* in under it, so the two overlap rather than one replacing the other. Kept
|
||||
* short, because a full-width copy sits over any neighbour it now shares a lane
|
||||
* with until it is gone.
|
||||
*/
|
||||
const val SETTLE_FADE_MILLIS: Int = 250
|
||||
|
||||
/**
|
||||
* 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
|
||||
val moveInFlight = moveInFlight()
|
||||
|
||||
// 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()
|
||||
}
|
||||
// Hold the landed copy until the write is done — including the whole time a
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself, so the copy dissolves onto a block that is already there.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
controller.release()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// A copy of a block that is still in the tree behind it; announcing
|
||||
// it again would just duplicate the event for the drag's duration.
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write
|
||||
// runs, so the release reads as the block settling rather than vanishing.
|
||||
val landed = controller.drag == null
|
||||
val lift by animateFloatAsState(
|
||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||
label = "drag-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
targetValue = if (handingOver) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "drag-handover",
|
||||
)
|
||||
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
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
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 {
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -53,14 +54,19 @@ 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.platform.LocalLayoutDirection
|
||||
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
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -74,7 +80,21 @@ 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.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
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
|
||||
@@ -93,8 +113,9 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -106,11 +127,6 @@ import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
@@ -298,9 +314,16 @@ private fun DayContent(
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
// 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.then(swipeModifier),
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
@@ -319,11 +342,24 @@ private fun DayContent(
|
||||
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)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -332,8 +368,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 +390,10 @@ private fun DaySuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -487,13 +527,15 @@ 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
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
// 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
|
||||
@@ -512,39 +554,19 @@ private fun Timeline(
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState),
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
@@ -552,11 +574,26 @@ 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)
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -570,13 +607,19 @@ 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() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -604,23 +647,40 @@ private fun DayColumnCard(
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// the rare column holding two occurrences of one series, which would
|
||||
// otherwise be two blocks under one key.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
height = place.height,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
@@ -634,7 +694,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 +721,36 @@ 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 draggable = moveAction != null && block.beginsOn(date, zone)
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
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 = draggable && dragController.ghosts(block, date)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = modifier
|
||||
// The source stays put as a ghost while its floating copy travels,
|
||||
// then fades out as the copy settles on its new slot.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) 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) {
|
||||
@@ -676,11 +763,8 @@ private fun EventBlock(
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
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.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.math.abs
|
||||
|
||||
/** 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,
|
||||
/** The lane band inside that box, where the chips themselves are seated. */
|
||||
val band: LayoutCoordinates?,
|
||||
val columnWidthPx: Float,
|
||||
val laneHeightPx: Float,
|
||||
val laneCount: Int,
|
||||
/**
|
||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||
* never mirrored, but the grid is, so the leftmost column is the *last* day
|
||||
* in Arabic.
|
||||
*/
|
||||
val isRtl: Boolean,
|
||||
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||
) {
|
||||
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||
fun seat(col: Int, lane: Int): Offset? {
|
||||
val band = band?.takeIf { it.isAttached } ?: return null
|
||||
val origin = band.positionInRoot()
|
||||
val column = if (isRtl) days.lastIndex - col else col
|
||||
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this row seats [event] on [date], or null when it doesn't hold it —
|
||||
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||
*/
|
||||
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||
} ?: return null
|
||||
return seat(col, lane)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither
|
||||
* id alone will do: the provider hands re-read instances new instance ids, and a
|
||||
* single-occurrence move writes an exception row with a new event id — the title
|
||||
* is what survives both.
|
||||
*/
|
||||
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||
chip.eventId == moved.eventId || chip.title == moved.title
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* A dropped chip, held on the day it landed on while the write runs. The grid
|
||||
* behind it still shows the old day until the provider notifies and the query
|
||||
* re-reads, so releasing at drop time would snap the event back for the
|
||||
* length of the write.
|
||||
*/
|
||||
var settling: 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. Stays
|
||||
* set through [settling], so the source never reappears under the copy.
|
||||
*/
|
||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Where the grid itself now draws the drop, in root coordinates — published
|
||||
* by whichever week row ends up holding it. The copy is dropped wherever the
|
||||
* finger was, but the grid seats it in a lane; without this the copy waits
|
||||
* out a flat hold beside the chip the grid has already drawn and then
|
||||
* cross-fades across the gap, which reads as the event taking its time to
|
||||
* arrive. With it the copy glides onto its seat and hands over there.
|
||||
*/
|
||||
var settledInRoot: Offset? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
||||
* [handOver] rather than at [release], for the same reason [liftedInstanceId]
|
||||
* is: held to the end it would keep the chip dim under a copy that has
|
||||
* already faded, and brighten it afterwards — a dip the eye reads as the
|
||||
* event flickering.
|
||||
*/
|
||||
private var settledGhost: Boolean by mutableStateOf(false)
|
||||
|
||||
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||
* the confirmation chip carries Undo for four seconds after the copy is long
|
||||
* gone. Not snapshot state: nothing draws from it.
|
||||
*/
|
||||
private var undoable: MonthChipDrag? = null
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = pointerInRoot - chipInRoot
|
||||
pointer = pointerInRoot
|
||||
sizePx = size
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
event = null
|
||||
grabDate = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||
* Matched on the landing day plus either the event row or its title: a
|
||||
* single-occurrence move writes an exception row with a new `eventId`, and
|
||||
* the provider hands re-read instances new ids, so neither alone identifies
|
||||
* the chip the drop became.
|
||||
*/
|
||||
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
||||
val landed = settling?.takeIf { settledGhost } ?: return false
|
||||
if (days == null || landed.targetDate !in days) return false
|
||||
return isSameEvent(event, landed.event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the row keyed [token] whether it now seats the settled chip, and take
|
||||
* its answer. The continuous style can show the same week twice, once in each
|
||||
* adjoining month, so both copies of the landing row will answer: the seat
|
||||
* nearest where the chip was let go is the one the finger was over.
|
||||
*/
|
||||
fun noteSettled(token: Any) {
|
||||
val landed = settling ?: return
|
||||
val target = landed.targetDate ?: return
|
||||
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||
val current = settledInRoot
|
||||
val closer = current == null ||
|
||||
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
||||
if (closer) settledInRoot = at
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a copy back for an undo, which moves the event exactly as the drop did
|
||||
* and so should read the same way rather than teleporting the chip. Nothing
|
||||
* here writes anything: it puts a chip on the journey the inverse write is
|
||||
* about to make, and the settle that follows is the drop's own.
|
||||
*
|
||||
* False when there is nothing to carry — no drop of this controller's to undo
|
||||
* (it happened in another view, or has already been undone), or the grid does
|
||||
* not seat the moved event where it would have to start from. The chip then
|
||||
* simply reappears on the day it came from, as it always did.
|
||||
*/
|
||||
fun beginUndo(): Boolean {
|
||||
val last = undoable ?: return false
|
||||
undoable = null
|
||||
val from = last.targetDate ?: return false
|
||||
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||
liftedInstanceId = last.event.instanceId
|
||||
settledGhost = true
|
||||
settledInRoot = null
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* End the drag. A real drop keeps its chip on the target day as [settling]
|
||||
* until [release] — slid over to that day's column, so it lands where the
|
||||
* grid is about to draw it rather than wherever the finger happened to be.
|
||||
*/
|
||||
fun finish(): MonthChipDrop? {
|
||||
val landed = drag
|
||||
val left = landed?.targetDate?.let(::columnLeft)
|
||||
cancel()
|
||||
val target = landed?.targetDate ?: return null
|
||||
settling = landed.copy(
|
||||
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
||||
)
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledGhost = true
|
||||
undoable = settling
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ghosting the source while the copy is still dissolving — un-ghosting
|
||||
* only at [release] leaves the chip under it dim and then brightens it, a
|
||||
* dip the eye reads as the event flickering.
|
||||
*/
|
||||
fun handOver() {
|
||||
liftedInstanceId = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/** Drop the copy, once it has faded into the grid's own chip. */
|
||||
fun release() {
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
handOver()
|
||||
}
|
||||
|
||||
/** Root x of [date]'s column, in whichever visible row shows that day. */
|
||||
private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row ->
|
||||
val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null
|
||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||
?: return@firstNotNullOfOrNull null
|
||||
val column = if (row.isRtl) row.days.lastIndex - index else index
|
||||
bounds.left + column * row.columnWidthPx
|
||||
}
|
||||
|
||||
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[if (row.isRtl) row.days.lastIndex - column else 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 }
|
||||
@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -59,9 +61,11 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
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
|
||||
@@ -77,7 +81,32 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
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.moveInFlight
|
||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||
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
|
||||
@@ -126,6 +155,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -384,13 +414,25 @@ 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()
|
||||
// Undo moves the event back, so the chip travels back too rather than
|
||||
// reappearing on its old day. The signal comes from the write, not
|
||||
// from the chip that offers it: that lives above every view.
|
||||
OnUndoStarted { chipDrag.beginUndo() }
|
||||
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,8 +469,104 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
MonthDragOverlay(chipDrag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait for a grid that never seats the drop — it landed in a day's
|
||||
* "+N" overflow, or on a day this month doesn't show, or the write failed and
|
||||
* nothing changed. The copy has to go either way.
|
||||
*/
|
||||
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||
|
||||
/** 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()
|
||||
val moveInFlight = moveInFlight()
|
||||
// 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)
|
||||
// Hold the landed chip until the write is done — including the whole time a
|
||||
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||
// it to its seat: a chip is dropped at whatever height the finger was at,
|
||||
// while the grid seats it in a lane, so the copy has a gap to close before
|
||||
// the two are the same chip and the hand-over can be invisible.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = controller.settledInRoot
|
||||
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||
val landed = controller.settling
|
||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||
if (seat == null) {
|
||||
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
} else {
|
||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||
gliding = true
|
||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||
delay(SETTLE_GRACE_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
controller.release()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// A copy of a chip that is still in the tree behind it; announcing it
|
||||
// again would just duplicate the event for the drag's duration.
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write
|
||||
// runs, so the release reads as the chip settling rather than vanishing.
|
||||
val lift by animateFloatAsState(
|
||||
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
|
||||
label = "chip-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
targetValue = if (handingOver) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "chip-handover",
|
||||
)
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||
IntOffset(
|
||||
(at.x - origin.x).roundToInt(),
|
||||
(at.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 {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1720,6 +1858,53 @@ 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 cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val density = LocalDensity.current
|
||||
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val dragging = dragController?.isDragging == true
|
||||
DisposableEffect(rowToken, dragController) {
|
||||
onDispose { dragController?.removeRow(rowToken) }
|
||||
}
|
||||
// Republished on layout *and* on every recomposition: the coordinates only
|
||||
// change on the former, but what the row draws — which the controller reads
|
||||
// to find a moved chip's seat — changes on the latter, and a re-read that
|
||||
// moves an event doesn't move the row it lands in.
|
||||
val publish = {
|
||||
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
|
||||
if (dragController != null && cell != null) {
|
||||
dragController.putRow(
|
||||
rowToken,
|
||||
MonthRowGeometry(
|
||||
days = week.days,
|
||||
cell = cell,
|
||||
band = bandCoordinates[0],
|
||||
columnWidthPx = cell.size.width / 7f,
|
||||
laneHeightPx = rowHeightPx,
|
||||
laneCount = MAX_EVENT_ROWS,
|
||||
isRtl = isRtl,
|
||||
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
SideEffect { publish() }
|
||||
// Once the grid holds the settled chip, tell the controller where this row
|
||||
// has seated it, so the copy in flight can glide onto it. Keyed on the week
|
||||
// because that is what changes when the re-read lands: until then the row
|
||||
// still draws the event on the day it came from and answers nothing.
|
||||
LaunchedEffect(week, dragController?.settling) {
|
||||
dragController?.noteSettled(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 +1920,21 @@ private fun MonthWeekRow(
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned { coords ->
|
||||
cellCoordinates[0] = coords
|
||||
publish()
|
||||
}
|
||||
.then(
|
||||
monthChipDragModifier(
|
||||
week = week,
|
||||
moveScope = moveScope,
|
||||
controller = dragController,
|
||||
band = bandCoordinates,
|
||||
rowHeightPx = rowHeightPx,
|
||||
isRtl = isRtl,
|
||||
),
|
||||
),
|
||||
) {
|
||||
val colW = maxWidth / 7
|
||||
|
||||
@@ -1800,7 +1999,13 @@ private fun MonthWeekRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
||||
.onGloballyPositioned {
|
||||
bandCoordinates[0] = it
|
||||
publish()
|
||||
}
|
||||
// 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 ->
|
||||
@@ -1810,6 +2015,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -1879,6 +2085,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -1959,6 +2166,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,
|
||||
isRtl: Boolean,
|
||||
): 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()
|
||||
// The column under the finger on screen, and the day that column shows.
|
||||
val column = (local.x / columnPx).toInt()
|
||||
val dayIndex = if (isRtl) week.days.lastIndex - column else column
|
||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||
null
|
||||
} else {
|
||||
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||
}
|
||||
if (event == null || moveScope?.allows(event) != true) {
|
||||
false
|
||||
} else {
|
||||
controller?.begin(
|
||||
event = event,
|
||||
grabDate = week.days[dayIndex],
|
||||
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 ->
|
||||
// How many columns the finger crossed — grabbing the middle of a
|
||||
// multi-day bar shifts the event by what the finger travelled, not
|
||||
// to where it landed.
|
||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||
if (delta != 0) {
|
||||
moveScope?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.ByDays(delta),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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
|
||||
@@ -2051,12 +2326,27 @@ private fun MonthBar(
|
||||
continuesLeft: Boolean,
|
||||
continuesRight: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
||||
* has to ghost like the source it came from until the copy has landed on it,
|
||||
* and the instance id can't find it: the provider hands the re-read instance a
|
||||
* new one, so the chip the drop became would otherwise sit at full opacity
|
||||
* under the copy still travelling towards it — the event drawn twice.
|
||||
*/
|
||||
days: List<LocalDate>? = null,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
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, then
|
||||
// fades out as the copy settles on its new day.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||
monthDrag?.isSettledChip(event, days) == true
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -2065,9 +2355,13 @@ private fun MonthBar(
|
||||
)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -62,13 +63,17 @@ 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.platform.LocalLayoutDirection
|
||||
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
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -84,6 +89,20 @@ 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.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
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
|
||||
@@ -104,8 +123,10 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
@@ -121,13 +142,10 @@ import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||
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,9 +349,16 @@ private fun WeekContent(
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
// 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.then(swipeModifier),
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
@@ -352,12 +377,25 @@ private fun WeekContent(
|
||||
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)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -366,9 +404,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 +425,10 @@ private fun WeekSuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -622,13 +664,15 @@ 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
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
// 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
|
||||
@@ -648,45 +692,40 @@ private fun Timeline(
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||
// as the header badge so the labels sit under it and on the hamburger.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.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
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
@@ -695,8 +734,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,13 +756,19 @@ 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() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -748,24 +795,41 @@ private fun DayColumnCard(
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// the rare column holding two occurrences of one series, which would
|
||||
// otherwise be two blocks under one key.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
width = laneWidth,
|
||||
height = place.height,
|
||||
width = place.width,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
@@ -780,7 +844,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 +890,36 @@ 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 draggable = moveAction != null && block.beginsOn(date, zone)
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
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 = draggable && dragController.ghosts(block, date)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
// The source stays put as a ghost while its floating copy travels,
|
||||
// then fades out as the copy settles on its new slot.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) 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) {
|
||||
@@ -841,11 +932,8 @@ private fun EventBlock(
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<string name="event_detail_share">Partager</string>
|
||||
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||
@@ -95,14 +95,14 @@
|
||||
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||
@@ -178,7 +178,7 @@
|
||||
<string name="event_access_confidential">Confidentiel</string>
|
||||
<string name="event_attendee_organizer">Organisateur</string>
|
||||
<string name="event_attendee_resource">Ressource</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="reminder_default">Rappel par défaut</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minute avant</item>
|
||||
@@ -283,7 +283,7 @@
|
||||
<string name="event_status_tentative">Provisoire</string>
|
||||
<string name="reminder_at_time">Au moment de l’événement</string>
|
||||
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
||||
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
||||
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
||||
@@ -331,14 +331,14 @@
|
||||
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
||||
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
||||
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
||||
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
||||
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
||||
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
<string name="settings_reminders">rappels d\'événements</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_default_reminder">rappel par défaut</string>
|
||||
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
||||
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
||||
@@ -383,9 +383,9 @@
|
||||
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
||||
<string name="settings_special_dates_paused_title">suspendu</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
||||
<string name="settings_section_about">A propos</string>
|
||||
<string name="settings_license">Licence</string>
|
||||
@@ -411,7 +411,7 @@
|
||||
<string name="calendars_edit_title">Editer un agenda</string>
|
||||
<string name="calendars_name_label">Nom</string>
|
||||
<string name="calendars_description_hint">Ajouter une description</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||
@@ -429,8 +429,8 @@
|
||||
<string name="calendars_auto_backup_interval">Intervalle</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
||||
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
||||
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
||||
@@ -481,7 +481,7 @@
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">créer un événement</string>
|
||||
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="crash_dialog_title">%1$s a planté</string>
|
||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
||||
<string name="crash_dialog_report">rapport</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,89 @@ 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 `undoing a whole-series move lands the anchor back where it started`() {
|
||||
// What makes undo safe for "all events": the shift is applied to the
|
||||
// anchor the provider currently holds, so re-issuing it with the two
|
||||
// forms swapped applies the exact inverse to the moved anchor.
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
||||
)
|
||||
|
||||
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
|
||||
assertThat(movedAnchor).isNotEqualTo(series)
|
||||
|
||||
val restored = update(moved, original, movedAnchor)
|
||||
assertThat(restored[CalendarContract.Events.DTSTART]).isEqualTo(series)
|
||||
assertThat(restored[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
||||
}
|
||||
|
||||
@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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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, berlin)
|
||||
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, berlin)
|
||||
|
||||
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 `a timed shift onto a DST changeover keeps the real length, not the wall clock`() {
|
||||
// 22:00 Sat -> 04:00 Sun is six real hours; the target Sunday is the one
|
||||
// Berlin springs forward on. Keeping wall clock would write a five-hour
|
||||
// DURATION for the whole series.
|
||||
val overnight = form(
|
||||
start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)),
|
||||
)
|
||||
|
||||
val moved = overnight.shiftedByDays(7, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
|
||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
||||
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
|
||||
// The wall-clock end therefore lands an hour later than a naive +7 days.
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifting by no days is a no-op`() {
|
||||
val original = form()
|
||||
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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 day-of-month rule is refused, because the anchor moves by days not dates`() {
|
||||
// BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1:
|
||||
// the rebuilt rule would say the 1st while the anchor became Jan 29 — a
|
||||
// DTSTART that is not an instance of its own rule. Weekday arithmetic is
|
||||
// uniform mod 7 and survives the same shift; day-of-month is not.
|
||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull()
|
||||
assertThat(
|
||||
realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)),
|
||||
).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BYDAY on a non-weekly rule is refused`() {
|
||||
// "every Monday of the month" is a shape this has not been reasoned about,
|
||||
// and parseSimpleRecurrence can't read it either — so the UNTIL guard
|
||||
// downstream would be blind to it.
|
||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule with no FREQ is refused`() {
|
||||
assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@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=WEEKLY;BYDAY=MO;BYSETPOS=1", monday, wednesday))
|
||||
.isNull()
|
||||
assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything realignable is also a rule the UNTIL guard can read`() {
|
||||
// problems() checks UNTIL through parseSimpleRecurrence; a rule this
|
||||
// realigns but that parser rejects would move a series past its own end
|
||||
// unchecked. Accepting only weekly BYDAY keeps the two in step.
|
||||
val realignable = listOf(
|
||||
"FREQ=WEEKLY;BYDAY=MO",
|
||||
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z",
|
||||
"FREQ=DAILY;COUNT=5",
|
||||
)
|
||||
realignable.forEach { rule ->
|
||||
assertThat(realignRecurrence(rule, monday, wednesday)).isNotNull()
|
||||
assertThat(parseSimpleRecurrence(realignRecurrence(rule, monday, wednesday)!!))
|
||||
.isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
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 in whatever zone the test JVM runs in — resolved
|
||||
// in the device zone rather than UTC, because that is the zone the drop path
|
||||
// reads dates back in, and at UTC+13 a UTC midday is already Tuesday. The
|
||||
// BYDAY assertions below depend on this really being a Monday.
|
||||
private val monday = LocalDate(2026, 6, 8)
|
||||
private val beginMillis = LocalDateTime(monday, LocalTime(12, 0))
|
||||
.toInstant(TimeZone.currentSystemDefault())
|
||||
.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.ByDays(2),
|
||||
)
|
||||
|
||||
/** 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())
|
||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging a bounded series' last occurrence still allows moving just it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
// The block belongs to the write, not to the drop: an exception row is
|
||||
// constrained by no UNTIL, so this scope must still be on offer.
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
|
||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedOccurrences).hasSize(1)
|
||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag that changes both the day and the time cannot move the series`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
// Onto the next day *and* two hours later: the anchor moves by the same
|
||||
// wall-clock shift, so an anchor late enough in the day would cross two
|
||||
// midnights and land on a weekday the rebuilt rule doesn't name.
|
||||
vm.move(
|
||||
MoveRequest(
|
||||
eventId = 42L,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(
|
||||
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ class TimeFormatTest {
|
||||
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gutter time drops the meridiem and clamps to the day`() {
|
||||
assertThat(formatGutterTime(9 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("09:15")
|
||||
assertThat(formatGutterTime(13 * 60 + 45, is24Hour = false, Locale.US)).isEqualTo("1:45")
|
||||
assertThat(formatGutterTime(0, is24Hour = false, Locale.US)).isEqualTo("12:00")
|
||||
assertThat(formatGutterTime(1_440, is24Hour = true, Locale.US)).isEqualTo("23:59")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
|
||||
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")
|
||||
|
||||
@@ -108,6 +108,61 @@ 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`, and
|
||||
returns **null** for everything else — 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.
|
||||
|
||||
What the rule has to agree with is the **series anchor**, not the occurrence
|
||||
being dragged, and the anchor moves by the same *wall-clock* shift. Only two
|
||||
things survive that intact, which is exactly the envelope the realigner accepts:
|
||||
weekday (uniform mod 7, so every anchor time of day crosses the same number of
|
||||
midnights) and a shift that is a whole number of days (otherwise a late-enough
|
||||
anchor crosses one midnight more than the occurrence did). Day-of-month is not
|
||||
uniform — `BYMONTHDAY=28` with a January anchor, occurrence Feb 28 dragged to
|
||||
Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1` rule, a DTSTART that
|
||||
is not an instance of its own rule and a phantom occurrence on any client that
|
||||
trusts it. Those drags may only move the one occurrence.
|
||||
|
||||
The accepted parts are deliberately a **subset** of what `parseSimpleRecurrence`
|
||||
understands, so anything realignable is also a rule whose `UNTIL` the guard
|
||||
below can actually read.
|
||||
- **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.
|
||||
|
||||
A drop that would push a series past its own `UNTIL` — the provider then
|
||||
generates zero occurrences and the event vanishes from every view — is refused
|
||||
rather than written. The check happens at **write** time, not at drop time,
|
||||
because which date has to clear `UNTIL` depends on how far the write reaches: a
|
||||
whole-series move carries the *anchor*, a split starts a new series at the moved
|
||||
occurrence, and a single occurrence becomes an exception row that no `UNTIL`
|
||||
constrains. Testing the occurrence in every case would refuse the perfectly
|
||||
ordinary drag of a bounded series' last occurrence.
|
||||
|
||||
Two known limitations of a whole-series move, both shared with the edit screen's
|
||||
own *All events* time save rather than introduced here — a drag just makes them
|
||||
one gesture away: the series' `EXDATE` stamps and its exception rows are **not**
|
||||
re-anchored, so previously deleted occurrences can reappear and previously
|
||||
modified ones stay behind while the rest of the series moves.
|
||||
|
||||
### Event time zones
|
||||
|
||||
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
||||
@@ -144,6 +199,22 @@ 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. Two further gaps, both narrow and
|
||||
accepted: a shift whose *anchor* crosses a DST gap is not invertible in wall
|
||||
clock (the −Δ normalises back to where it started), and an undo after a
|
||||
concurrent remote time change overwrites it, exactly as the forward move would.
|
||||
|
||||
## Reminder delivery
|
||||
|
||||
Calendula plans and fires its own reminders. It reads the offsets in
|
||||
|
||||
Submodule floret-kit updated: ed1d3ca5e8...71a4f371b6
Reference in New Issue
Block a user