Drag an event to reschedule it (#135)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/135
This commit is contained in:
@@ -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,9 @@ 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; "no RRULE" alone
|
||||
// can't tell an exception from a master (#68).
|
||||
CalendarContract.Events.ORIGINAL_ID,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -110,6 +113,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,14 @@ 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]: a managed event stays editable (reminders,
|
||||
* notes) yet must never move, since 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,53 @@
|
||||
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].
|
||||
*/
|
||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||
|
||||
/**
|
||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||
* wall clock instead would rewrite the series' length across a DST boundary.
|
||||
*
|
||||
* All-day events are date-anchored — use [shiftedByDays]; 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 span.
|
||||
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
||||
* duration, for the same reason [shiftedTo] does.
|
||||
*/
|
||||
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,12 @@ 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) rather than a master, so a reschedule 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,48 @@
|
||||
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 `FREQ=WEEKLY;BYDAY=MO`
|
||||
* would keep naming Monday after the anchor became a Wednesday and the series
|
||||
* would not move at all — `BYDAY` is 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*, and weekday arithmetic is the only kind
|
||||
* that survives the same wall-clock shift unchanged. Everything else returns
|
||||
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
||||
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], 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.
|
||||
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,11 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||
// right after the calendar views so later overlays cover it.
|
||||
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
|
||||
* slides to its new slot, so the label shouldn't 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. 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 on the first frame.
|
||||
*
|
||||
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
||||
LocalTimelineZoom.current.isPinching
|
||||
) {
|
||||
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,150 @@
|
||||
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 — well under touch slop. */
|
||||
private val PICKUP_TOLERANCE = 6.dp
|
||||
|
||||
/**
|
||||
* Pick an event block up with a long press and drag it, without
|
||||
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||
* ancestor consumes or the finger leaves the block, which a
|
||||
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
||||
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
||||
*/
|
||||
@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. [onPickUp] receives the press position local to this node and answers
|
||||
* whether anything is there; false abandons the gesture.
|
||||
*/
|
||||
@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 {
|
||||
// Driven on the initial pass, which runs parent → child: an
|
||||
// ancestor that outranks us (the pinch) has already consumed
|
||||
// by the time we look, and descendants — the month grid's
|
||||
// full-bleed tap layer — see our consumption.
|
||||
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 —
|
||||
// a cancel, not a drop, and must never write.
|
||||
if (dropped) currentDrop() else currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
||||
@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,144 @@
|
||||
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, since it offers nothing to act on. */
|
||||
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
|
||||
* calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
|
||||
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||
*/
|
||||
@Composable
|
||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||
val writeInFlight by viewModel.inFlight.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, which would land a frame late and open the chip on stale text.
|
||||
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
|
||||
// Restarted by the undo tick and held while a write is in flight: an undo
|
||||
// tapped in the last moments of the window leaves the outcome at Moved on
|
||||
// purpose, and this timer would otherwise fire mid-undo and close the chip
|
||||
// just before the same chip has to say "undone".
|
||||
LaunchedEffect(outcome, undoTick, writeInFlight) {
|
||||
if (outcome == null || writeInFlight) 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 at the bottom start with the FAB's own margin, so
|
||||
// the chip lines up beside the bottom-end FAB.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.navigationBarsPadding()
|
||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// The action goes away while a write runs: undo() refuses a second
|
||||
// write anyway, so offering it would be a button that does nothing.
|
||||
val undo = content.undo?.takeIf { !writeInFlight }
|
||||
SnackChip(
|
||||
visible = outcome != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||
onAction = undo?.let { { viewModel.undo(it) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "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,109 @@
|
||||
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. Null means moving is off entirely and
|
||||
* blocks register no drag gesture at all.
|
||||
*/
|
||||
@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>,
|
||||
/** False when the drop was refused outright, so nothing will be written. */
|
||||
val move: (MoveRequest) -> Boolean,
|
||||
/**
|
||||
* 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 — every visible block reads it as a composition local.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@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, so the block
|
||||
* appears to travel to its new slot instead of vanishing and reappearing.
|
||||
*/
|
||||
@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.
|
||||
*/
|
||||
@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,119 @@
|
||||
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: the drag is rewritten every frame, its snapped start once a slot.
|
||||
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 already announces this time.
|
||||
.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,69 @@
|
||||
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. Shared by the edit
|
||||
* screen's save and a drag-and-drop reschedule; one of the two carve-outs from
|
||||
* the full-screen picker rule.
|
||||
*
|
||||
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
||||
* rule); [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,463 @@
|
||||
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
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
/** 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, so the screen and this view model can't
|
||||
* disagree by a day over which zone the event's first day is resolved in.
|
||||
*/
|
||||
data class ByDays(val days: Int) : MoveTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
||||
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
|
||||
*/
|
||||
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 a wider write would leave the rule and the series
|
||||
* anchor disagreeing — either the rule names days one moved occurrence can't
|
||||
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||
* across the same midnights the occurrence crossed.
|
||||
*/
|
||||
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 — a stripped one would wipe the
|
||||
* reminders and attendees the occurrence-exception path reconciles.
|
||||
*/
|
||||
@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.
|
||||
*/
|
||||
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, so the view that drew the drop can carry its chip back rather than
|
||||
* let it reappear on the old day.
|
||||
*/
|
||||
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 inside that window would compute their shift
|
||||
* from the same pre-move occurrence, and 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, 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. */
|
||||
val canRealign: Boolean,
|
||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||
val newAnchorDate: LocalDate,
|
||||
)
|
||||
|
||||
/**
|
||||
* Take a drop, unless one is already being written. False means nothing will
|
||||
* be written and the caller must let its held copy go now — waiting on
|
||||
* [inFlight] would strand it until the settle timeout instead.
|
||||
*/
|
||||
fun move(request: MoveRequest): Boolean {
|
||||
if (busy || _scopePrompt.value != null) return false
|
||||
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)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Answer the scope dialog. */
|
||||
fun moveWithScope(scope: RecurringWriteScope) {
|
||||
val prepared = pending ?: return
|
||||
// Guard against the dialog ever offering a scope the rule can't carry.
|
||||
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 stands until the
|
||||
* inverse write reports back, so the one chip changes what it says rather
|
||||
* than closing and reopening. False when another write is already running —
|
||||
* the chip hides its action for that window, so this is the backstop.
|
||||
*/
|
||||
fun undo(undo: MoveUndo): Boolean {
|
||||
if (busy) return false
|
||||
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
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 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.
|
||||
if (shifted == original) return null
|
||||
|
||||
// The UNTIL check is deferred to the write, which knows how far the move
|
||||
// reaches and so which date has to clear it.
|
||||
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 carries it across the same number
|
||||
// of midnights whatever time of day it sits at. A same-date drag has its
|
||||
// own, narrower check — see anchorKeepsItsDay.
|
||||
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 = when {
|
||||
!isRecurring -> true
|
||||
movedDay -> realigned != null
|
||||
else -> anchorKeepsItsDay(detail, original, shifted, zone)
|
||||
},
|
||||
// Where the series row's own DTSTART lands, given the anchor moves by
|
||||
// the same shift. Only read under canRealign, which is what
|
||||
// guarantees the anchor really does travel this many days.
|
||||
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 at all. Which date
|
||||
* has to clear `UNTIL` depends on the scope: a whole-series move carries the
|
||||
* series *anchor*, a split starts at the moved occurrence, and a single
|
||||
* occurrence becomes an exception row that no `UNTIL` constrains.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a same-date drag leaves the series anchor on its own day too. The
|
||||
* anchor moves by the same wall-clock delta as the occurrence, and normally
|
||||
* shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the
|
||||
* two in zones that can sit a DST hour apart, so a near-midnight drag could
|
||||
* carry the anchor across a midnight the occurrence never crossed and leave
|
||||
* `BYDAY` naming the wrong weekday.
|
||||
*/
|
||||
private fun anchorKeepsItsDay(
|
||||
detail: EventDetail,
|
||||
original: EventForm,
|
||||
shifted: EventForm,
|
||||
zone: TimeZone,
|
||||
): Boolean {
|
||||
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
||||
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
||||
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
||||
val delta = (
|
||||
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||
) / MILLIS_PER_MINUTE
|
||||
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, and a whole-series move. "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: UTC midnight for an all-day event.
|
||||
*/
|
||||
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 — the hour labels around it
|
||||
* already carry it, and the gutter is too narrow.
|
||||
*/
|
||||
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,484 @@
|
||||
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. Plain fields rather
|
||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||
* it per frame anyway.
|
||||
*/
|
||||
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 mapping has to flip with it.
|
||||
*/
|
||||
var isRtl: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
||||
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
||||
* block it renders is drawn in an overlay, clear of the day column's clip.
|
||||
*/
|
||||
@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 query re-reads.
|
||||
*/
|
||||
var settling: TimelineDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which block is lifted, and whether anything is. Separate snapshot state
|
||||
* from [drag], which changes every frame, so blocks that only need "am I the
|
||||
* ghost" don't recompose that often. Stays set through [settling].
|
||||
*/
|
||||
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 carrying the
|
||||
* *old* time can arrive under a new instance id.
|
||||
*/
|
||||
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, or the source ghost
|
||||
* flashes back to full opacity in its old slot.
|
||||
*/
|
||||
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 — not the same
|
||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||
*/
|
||||
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: the instance the finger picked up, or once dropped whatever now
|
||||
* sits in the slot it left.
|
||||
*/
|
||||
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, since a single-occurrence move writes a new `eventId`.
|
||||
*/
|
||||
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 dissolving. Un-ghosting at [release] instead would leave
|
||||
* the block dim under the fading copy and then brighten it: a visible dip.
|
||||
*/
|
||||
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 past midnight is fine.
|
||||
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
||||
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
||||
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
||||
// 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, since a loop of
|
||||
* `animateScrollBy` would re-acquire the mutex and cancel 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 — it landed on a day
|
||||
* this timeline doesn't show, or the write failed. 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. 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 time a
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself.
|
||||
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 still in the tree behind it; announcing it again
|
||||
// would 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.
|
||||
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) }
|
||||
// An end past midnight wraps rather than saturating, so a four-hour
|
||||
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
||||
drag.endMin % MINUTES_PER_DAY
|
||||
} else {
|
||||
drag.endMin
|
||||
}
|
||||
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||
formatMinuteOfDay(endMin, 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,14 @@ class TimelineZoom(
|
||||
var scale: TimelineScale by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
private var pinching = false
|
||||
/**
|
||||
* Whether fingers are rescaling the timeline right now. Snapshot state, so
|
||||
* anything that tweens off [scale] can stand down for the gesture: the
|
||||
* height changes every pointer frame, and a spring would spend the whole
|
||||
* pinch chasing a target that has already moved.
|
||||
*/
|
||||
var isPinching: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
||||
@@ -49,11 +56,11 @@ class TimelineZoom(
|
||||
* snap the timeline back while the user is still pinching.
|
||||
*/
|
||||
fun adopt(stored: TimelineScale) {
|
||||
if (!pinching) scale = stored
|
||||
if (!isPinching) scale = stored
|
||||
}
|
||||
|
||||
fun beginPinch() {
|
||||
pinching = true
|
||||
isPinching = true
|
||||
}
|
||||
|
||||
fun pinchTo(hourHeight: Dp) {
|
||||
@@ -61,7 +68,7 @@ class TimelineZoom(
|
||||
}
|
||||
|
||||
fun endPinch() {
|
||||
pinching = false
|
||||
isPinching = false
|
||||
persist(scale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,31 +314,54 @@ private fun DayContent(
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
val took = move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
// Refused, so nothing will land: let the copy go now
|
||||
// rather than hold it out for a settle that never comes.
|
||||
if (took != true) dragController.release()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,8 +371,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 +393,10 @@ private fun DaySuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -487,13 +530,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 +557,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 +577,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 +610,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,22 +650,38 @@ 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
|
||||
// a column holding two occurrences of one series.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
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
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
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 = place.height,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.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) {
|
||||
@@ -634,7 +696,10 @@ private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
date: LocalDate,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -658,12 +723,35 @@ 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(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 +764,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,303 @@
|
||||
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 mapping has to flip with it.
|
||||
*/
|
||||
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: re-read instances get new instance ids, and a single-occurrence
|
||||
* move writes a new event id — the title 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 target resolved against
|
||||
* whichever registered row the finger is over. Rows are keyed by an identity
|
||||
* token rather than a date: the continuous style can show the same week twice.
|
||||
*/
|
||||
@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 query re-reads.
|
||||
*/
|
||||
var settling: MonthChipDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
||||
* [drag], which changes every frame, so the chips and rows that only need
|
||||
* "am I the ghost" don't recompose that often. Stays set through [settling].
|
||||
*/
|
||||
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 while the grid seats it in a lane, so without this it would
|
||||
* cross-fade across the gap instead of gliding onto its seat.
|
||||
*/
|
||||
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 [release], or the chip stays dim under a copy that
|
||||
* has already faded and brightens afterwards: a visible dip.
|
||||
*/
|
||||
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 long after the copy is gone.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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. Both
|
||||
* copies of a week the continuous style shows twice 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a chip on the journey an undo's inverse write is about to make, so it
|
||||
* travels back rather than teleporting. Writes nothing itself. False when
|
||||
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||
* seat the moved event where the chip would have to start from.
|
||||
*/
|
||||
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, handing back where it landed — null when it never resolved,
|
||||
* or when it landed back on the day it came from. 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.
|
||||
*/
|
||||
fun finish(): MonthChipDrop? {
|
||||
val landed = drag
|
||||
val left = landed?.targetDate?.let(::columnLeft)
|
||||
cancel()
|
||||
val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: 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
|
||||
* at [release] instead leaves the chip dim and then brightens it.
|
||||
*/
|
||||
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: 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,23 @@ fun MonthScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||
// overlay, clear of the week row's and the list viewport's clips.
|
||||
val chipDrag = rememberMonthDragController()
|
||||
// Undo moves the event back, so the chip travels back too. The
|
||||
// signal comes from the write, not from the chip that offers it.
|
||||
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,10 +467,103 @@ 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.
|
||||
*/
|
||||
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 time a
|
||||
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||
// it to its seat: it was dropped at whatever height the finger was at, while
|
||||
// the grid seats it in a lane.
|
||||
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 still in the tree behind it; announcing it again
|
||||
// would 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.
|
||||
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
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
@@ -1720,6 +1853,50 @@ 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 sits on top of them — 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 change
|
||||
// only on the former, but what the row draws — which the controller reads to
|
||||
// find a moved chip's seat — changes on the latter.
|
||||
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,
|
||||
// which is what changes when the re-read lands.
|
||||
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 +1912,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 +1991,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
|
||||
// yields for it 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 +2007,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 +2077,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -1959,6 +2158,73 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row-level pickup for month chips: resolves which chip the press landed on
|
||||
* from the geometry the row just laid out, and abandons the gesture on empty
|
||||
* space (or an unmovable calendar) 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 by what it travelled, not to where it landed.
|
||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||
val took = moveScope?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.ByDays(delta),
|
||||
),
|
||||
)
|
||||
// Refused, so nothing will land: let the copy go now rather than
|
||||
// hold the chip out for a settle that can never arrive.
|
||||
if (took != true) controller?.release()
|
||||
}
|
||||
},
|
||||
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 +2317,24 @@ 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 until the copy lands on it, and the instance id can't find it:
|
||||
* the provider hands the re-read instance a new one.
|
||||
*/
|
||||
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.
|
||||
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 +2343,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,23 @@ 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.
|
||||
* 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,32 +349,55 @@ private fun WeekContent(
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
val took = move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
// Refused, so nothing will land: let the copy go now
|
||||
// rather than hold it out for a settle that never comes.
|
||||
if (took != true) dragController.release()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,9 +407,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 +428,10 @@ private fun WeekSuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -622,13 +667,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 +695,39 @@ 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.
|
||||
.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 +736,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 +758,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,23 +797,39 @@ 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
|
||||
// a column holding two occurrences of one series.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
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
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
width = laneWidth,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
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 = place.height,
|
||||
width = place.width,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.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) {
|
||||
@@ -780,7 +845,10 @@ private fun EventBlock(
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
date: LocalDate,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -823,12 +891,35 @@ 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(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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">Moving the whole series would change which days it falls on, 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,88 @@ 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, while RRULE is
|
||||
// written verbatim: without realignRecurrence 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,26 @@ 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`() {
|
||||
// The predicate is deliberately not isEventTarget.
|
||||
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,155 @@
|
||||
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: 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, onto the Sunday Berlin
|
||||
// springs forward on. Keeping wall clock would write five.
|
||||
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,111 @@
|
||||
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.
|
||||
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`() {
|
||||
// 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 go unchecked.
|
||||
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,497 @@
|
||||
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 the device zone — the zone the drop path
|
||||
// reads dates back in. The BYDAY assertions depend on it 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,
|
||||
anchorMillis: Long = beginMillis,
|
||||
): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
|
||||
start = Instant.fromEpochMilliseconds(anchorMillis),
|
||||
end = Instant.fromEpochMilliseconds(anchorMillis + (endMillis - beginMillis)),
|
||||
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 `a time-only drop that would carry the anchor past midnight offers only the occurrence`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
// The anchor sits at 23:30 while the dragged occurrence is at midday —
|
||||
// only possible when the row pins no zone, so the two resolve an hour
|
||||
// apart. +1h leaves the occurrence on its day but rolls the anchor onto
|
||||
// the next one, which would leave BYDAY naming the wrong weekday.
|
||||
val anchor = LocalDateTime(LocalDate(2026, 6, 1), LocalTime(23, 30))
|
||||
.toInstant(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds()
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO", anchorMillis = anchor) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second drop is refused while the first is still in flight`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
assertThat(vm.move(toWednesday())).isTrue()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Parked on the scope dialog, so the first drop still owns the pipeline.
|
||||
assertThat(vm.move(oneHourLater())).isFalse()
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo is refused while a write is running`(@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!!
|
||||
|
||||
// A second drop parks on its scope dialog, which holds the pipeline.
|
||||
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.undo(undo)).isFalse()
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
}
|
||||
|
||||
@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()
|
||||
// An exception row is constrained by no UNTIL, so the scope stays 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: a late enough anchor would
|
||||
// cross two midnights under the same wall-clock shift.
|
||||
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,83 @@ 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 `buildEventUpdateValues` moves that anchor by the same
|
||||
*wall-clock* shift. So the realigner only touches weekday, which is uniform mod
|
||||
7 and therefore survives that shift whatever time of day the anchor sits at.
|
||||
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.
|
||||
|
||||
Uniform mod 7 is not enough on its own: the anchor also has to cross the *same
|
||||
number of midnights* as the occurrence did, or the rebuilt weekday is off by a
|
||||
day. `RescheduleViewModel` requires that of every write wider than one
|
||||
occurrence, in the two shapes a drag comes in:
|
||||
|
||||
- a drag that **changes the day** must be a whole number of days, so the two
|
||||
move in step whatever the anchor's time of day;
|
||||
- a **time-only** drag must leave the anchor on its own day. That is normally
|
||||
free, since the anchor shares the occurrence's time of day — but a row with
|
||||
no `EVENT_TIMEZONE` resolves anchor and occurrence in zones that can sit a
|
||||
DST hour apart, and a near-midnight drag would then carry the anchor across a
|
||||
midnight the occurrence never crossed.
|
||||
|
||||
Whatever falls outside — a rule the realigner won't rebuild, or a shift that
|
||||
would move rule and anchor out of step — 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.
|
||||
|
||||
**One drop is written at a time.** Two drops of the same recurring event landing
|
||||
inside one write window would each compute their shift from the same pre-move
|
||||
occurrence, while the data layer applies both to the re-read anchor — so the
|
||||
shifts would compound. `RescheduleViewModel.move` therefore refuses while a write
|
||||
(or its scope dialog) is outstanding, and says so in its return value: the view
|
||||
that took the drop releases the block it was holding on the target instead of
|
||||
waiting out a settle that will never arrive.
|
||||
|
||||
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 +221,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 on the confirmation chip
|
||||
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