Compare commits

...

5 Commits

Author SHA1 Message Date
b64d04f8e3 Merge remote-tracking branch 'origin/main' into feat/drag-to-reschedule 2026-08-02 20:27:48 +02:00
63ea07dd0b Bring the drag docs in line with what the code does (#68)
The realignment envelope is described as implemented: whole-day shifts, or
a time-only shift whose anchor keeps its day. Also documents the one-write-
at-a-time gate, and notes the drag as a third recurring-scope prompt.
2026-08-02 20:27:16 +02:00
097b494c4a Fix the drag review's findings (#68)
- a zero-day month drop no longer overwrites the pending undo record
- the chip's dismissal timer restarts on undo and waits out any write
- a refused drop releases its held copy instead of timing out
- the drag preview uses the event's real length, not the day-clipped block
- block placement stops springing while a pinch rewrites the hour height
- a time-only drag checks the series anchor stays on its own day
2026-08-02 20:27:16 +02:00
916f64d2df Trim the drag comments down to what they have to say (#68) 2026-08-02 19:29:58 +02:00
Jean-Luc Makiolas Weblate Bot
44233cff35 Translations update from Weblate (#130)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Renovate / renovate (push) Successful in 1m37s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/130
2026-08-02 17:45:31 +02:00
29 changed files with 660 additions and 455 deletions

View File

@@ -89,10 +89,8 @@ internal object EventDetailProjection {
// Recurring rows carry DURATION instead of DTEND; the detail screen
// needs it to render a series opened without a named occurrence.
CalendarContract.Events.DURATION,
// Non-null on a modified-occurrence exception row (it points at the
// series). "No RRULE" alone can't tell an exception from a master — a
// sync adapter that leaves the rule on the exception would otherwise
// get an exception written against an exception (#68).
// Non-null on a modified-occurrence exception row; "no RRULE" alone
// can't tell an exception from a master (#68).
CalendarContract.Events.ORIGINAL_ID,
)

View File

@@ -46,10 +46,8 @@ val CalendarSource.isEventTarget: Boolean
/**
* Whether this calendar's events may have their times rewritten by a drag (#68).
* Deliberately not [isEventTarget]: an event living in a switched-off or
* non-syncing calendar isn't rendered anyway, while a *managed* event is
* editable (reminders, notes) yet must never move — the next contacts sync would
* put it back.
* 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

View File

@@ -11,22 +11,17 @@ import kotlin.time.Instant
/**
* The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
* An unparseable pinned id falls back to the device, like the write path does.
*/
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
/**
* The form moved so it starts at [newStart], keeping its length. The wall-clock
* values are re-derived in the event's own zone, not the device's, so dragging a
* pinned event still means what the event means.
* The 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.
*
* The **instant** duration is preserved, not the wall-clock span: a recurring
* event's length travels to the provider as `DURATION`, so preserving wall clock
* would rewrite a whole series' length whenever a shift crosses a DST boundary.
*
* All-day events carry placeholder times and are date-anchored — move them with
* [shiftedByDays] instead; this returns them untouched.
* All-day events are date-anchored — use [shiftedByDays]; this returns them
* untouched.
*/
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
@@ -39,14 +34,9 @@ fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
}
/**
* The form moved [days] calendar days, keeping its time of day and its length.
* A multi-day event keeps its span rather than collapsing to a single day.
*
* An all-day event is pure date arithmetic — both ends move, and the placeholder
* times ride along untouched. A timed one re-derives its end from the preserved
* **instant** duration, for the same reason [shiftedTo] does: the length travels
* to a recurring row as `DURATION`, so a move onto a DST changeover that kept
* wall clock would silently rewrite the whole series' length.
* 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

View File

@@ -126,9 +126,8 @@ data class EventDetail(
val eventColorKey: String? = null,
/**
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
* set), not a master. Such a row stands alone — writing an exception against
* it would nest one exception inside another — so a reschedule always takes
* the plain whole-row path, whatever [rrule] a sync adapter left on it.
* 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,
)

View File

@@ -5,29 +5,16 @@ import kotlinx.datetime.LocalDate
/**
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
*
* `Events.RRULE` is written verbatim while DTSTART moves, so a rule that names
* its own day — `FREQ=WEEKLY;BYDAY=MO`, what Google and CalDAV write for nearly
* every weekly series — would keep pointing at Monday after the anchor became a
* Wednesday, and the series would not move at all. `BYDAY` is therefore
* re-derived from [newStart].
* `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*, which is not the occurrence being
* dragged: the anchor moves by the same wall-clock shift, and only weekday
* arithmetic survives that unchanged, because it is uniform mod 7 and every
* anchor time-of-day crosses the same number of midnights. Day-of-month does not
* — `BYMONTHDAY=28` on a January anchor dragged from Feb 28 to Mar 1 gives an
* anchor of Jan 29 under a `BYMONTHDAY=1` rule, which is not an instance of its
* own rule and materialises a phantom occurrence on any client that trusts
* DTSTART. Those rules return null instead. The caller must also refuse when the
* shift is not a whole number of days, for the same reason (see
* `RescheduleViewModel`).
*
* Null also covers rules one moved occurrence cannot resolve at all
* (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …) — dropping the extra days
* would delete occurrences. The accepted parts are deliberately a subset of what
* [parseSimpleRecurrence] understands, so anything realignable is also a rule
* [problems] can check the `UNTIL` of.
* 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
@@ -54,8 +41,7 @@ fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate):
else -> return null
}
}
// BYDAY is only simple on a weekly rule (matching parseSimpleRecurrence);
// "every Monday of the month" is a shape this has not been reasoned about.
// 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(";")

View File

@@ -398,9 +398,8 @@ fun CalendarHost(
}
}
// Scope prompt + confirmation/undo snackbar for a dropped event. Declared
// right after the calendar views, so any overlay opened afterwards covers
// the snackbar rather than the other way round.
// 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

View File

@@ -17,9 +17,8 @@ import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/**
* A timed block's own time label, crossfaded rather than replaced. The block
* keeps its identity across a move and slides to the new slot; the label is the
* one thing on it that would otherwise change in a single frame.
* 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
@@ -50,19 +49,20 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
/**
* A timed block's placement, tweened rather than jumped. Every bound a block
* has changes for a reason the user just caused — a drop landing at a new time,
* an undo putting it back, a neighbour arriving and halving both lanes — and all
* of them read better as motion than as a new layout appearing.
* 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.
*
* Continuity comes from the caller keying each block by identity; a block
* composed for the first time starts *at* its target, so nothing flies in from
* the corner on the first frame.
* 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()) {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
LocalTimelineZoom.current.isPinching
) {
snap()
} else {
MaterialTheme.motionScheme.fastSpatialSpec()

View File

@@ -21,29 +21,15 @@ import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
/**
* How far the finger may wander during the hold. Deliberately well under touch
* slop (~18dp): past that an ancestor — the vertical scroll, the page swipe —
* claims the gesture, and a hold that survived to there would have opened a dead
* zone where neither the drag nor the page turn happens.
*/
/** 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 the press as soon as an ancestor consumes — and
* during the hold the block itself consumes nothing, so the scroll and the page
* swipe are free to claim at their own slop — and it also cancels when the finger
* leaves the block, which a `MIN_EVENT_FRACTION`-tall block loses immediately.
* This one keeps its own timeout and its own (much smaller) tolerance, and never
* cancels on leaving the bounds.
*
* Movement before the timeout is deliberately **not** consumed: consuming it
* would kill the ancestor's gesture outright, so a scroll that happens to start
* on top of a block would die. Fast movement means the user meant to scroll —
* abandon quietly. A second finger means a pinch, which owns the gesture.
* `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(
@@ -68,9 +54,8 @@ fun rememberEventDragSource(
/**
* The same pickup, for a surface that carries many draggable pieces rather than
* being one itself — the month grid, whose chips are covered by a full-bleed tap
* layer and so can never take pointer input of their own. [onPickUp] receives the
* press position local to this node and answers whether anything is there;
* returning false abandons the gesture as if the hold had never completed.
* layer. [onPickUp] receives the press position local to this node and answers
* whether anything is there; false abandons the gesture.
*/
@Composable
fun rememberDragSurface(
@@ -121,16 +106,10 @@ fun rememberDragSurface(
if (!took) return@awaitEachGesture
var dropped = false
try {
// Once the block is lifted the gesture is ours, so it is
// driven on the initial pass and consumed there. That pass
// runs parent → child, which gets both halves right: an
// ancestor that outranks us (the pinch, which claims the
// moment a second finger lands anywhere in the timeline) has
// already consumed by the time we look, and everything below
// us — the month grid's full-bleed tap layer, which is a
// descendant and would otherwise open the day on lift — sees
// ours. Consumption persists across passes, so the scroll and
// the page swipe stand down on main as well.
// 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
@@ -146,19 +125,15 @@ fun rememberDragSurface(
?.let { currentMove(it.localToRoot(change.position)) }
}
} finally {
// Also reached when the pointer node is disposed mid-drag (the
// page swapping out under the finger) — that is a cancel, not a
// drop, and must never write.
// Also reached when the pointer node is disposed mid-drag
// a cancel, not a drop, and must never write.
if (dropped) currentDrop() else currentCancel()
}
}
}
}
/**
* The app's first haptics: a lift on pickup, then a tick every time the drop
* target snaps to a different slot, so the granularity is felt rather than read.
*/
/** 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

View File

@@ -32,12 +32,7 @@ import java.util.Locale
/** How long the confirmation chip stays up, matching a short snackbar. */
private const val CHIP_MILLIS = 4_000L
/**
* How long an *undone* move stays up. Shorter than everything else the chip
* says: it offers nothing to act on, and it confirms a change the user has just
* asked for and can see on the grid behind it, so the full dwell is only the
* chip outstaying what it had to say.
*/
/** 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. */
@@ -48,17 +43,16 @@ private val FAB_BAND = 88.dp
/**
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
* recurring-scope prompt, and the confirmation chip carrying Undo.
*
* None of the four calendar screens sets a `snackbarHost` on its Scaffold, so
* this hosts its own confirmation — a pill on the FAB's own band at the bottom
* start rather than a full-width bar, so the calendar it confirms a change to
* stays visible behind it.
* 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
@@ -86,25 +80,27 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
}
// Held past the outcome being consumed so the chip has something to draw
// while it springs back out. Updated *in composition* rather than from an
// effect: an effect lands a frame late, so the chip would open carrying the
// previous message and grow into this one while it was still animating in.
// 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
LaunchedEffect(outcome) {
if (outcome == null) return@LaunchedEffect
// 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 anchored at the bottom start with the FAB's own
// margin; centring the chip in it lines it up beside the bottom-end FAB
// at exactly its height, rather than sitting a touch above it.
// 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)
@@ -113,13 +109,15 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
.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 { content.undo != null },
onAction = content.undo?.let { undo -> { viewModel.undo(undo) } },
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
onAction = undo?.let { { viewModel.undo(it) } },
)
}
}

View File

@@ -19,13 +19,8 @@ import kotlinx.coroutines.flow.StateFlow
/**
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
* whichever event block is being composed. A composition local rather than six
* layers of parameters: every calendar surface needs the same three things, and
* the blocks that need them sit deep inside private composables.
*
* Null means moving is off entirely (no host provided it) — a block then
* registers no drag gesture at all, so a long press keeps its old meaning of
* nothing happening.
* whichever event block is being composed. Null means moving is off entirely and
* blocks register no drag gesture at all.
*/
@Immutable
class EventMoveScope(
@@ -34,12 +29,12 @@ class EventMoveScope(
* the repository writes whatever it is handed — so this gate is load-bearing.
*/
val movableCalendarIds: Set<Long>,
val move: (MoveRequest) -> Unit,
/** 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
* True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same
* object across a move: it is a composition local every visible block reads,
* and replacing it would recompose all of them twice per drop.
* 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`. */
@@ -68,8 +63,7 @@ fun moveInFlight(): Boolean {
/**
* Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen — a view switched to *after* an undo has
* nothing left to carry back.
* this composable came on screen.
*/
@Composable
fun OnUndoStarted(onUndo: () -> Unit) {
@@ -88,10 +82,8 @@ const val GHOST_ALPHA: Float = 0.3f
/**
* Opacity for a block whose copy is in flight: ghosted from the lift until the
* copy is handed back, then animated up rather than switched. The ghost keeps
* its place through the write and *travels* to the new slot when the grid
* re-reads it, arriving under the copy as that fades — so what the eye follows
* is one block moving, not one vanishing and another appearing.
* 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(
@@ -101,8 +93,7 @@ fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
/**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved — the block then carries no
* action and registers no drag either.
* pointer-only. Null when this event can't be moved.
*/
@Composable
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {

View File

@@ -56,8 +56,7 @@ fun HourGutter(
) {
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
// Derived, not read directly: the drag is rewritten every frame, while its
// snapped start only changes once per slot — which is all the gutter shows.
// Derived: the drag is rewritten every frame, its snapped start once a slot.
val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin }
}
@@ -103,8 +102,7 @@ fun HourGutter(
.height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp)
// The dragged block behind it already carries this time; a
// second copy would only duplicate the announcement.
// The dragged block already announces this time.
.clearAndSetSemantics { },
contentAlignment = Alignment.Center,
) {

View File

@@ -15,17 +15,13 @@ import de.jeanlucmakiola.floret.components.OptionCard
/**
* How far a write to a recurring event should reach: this occurrence, it and
* everything after (a series split), or the whole series.
*
* One of the two carve-outs from the full-screen picker rule — a two-or-three
* option decision taken mid-action reads better as a popup than as a near-empty
* screen. Shared by the edit screen's save and a drag-and-drop reschedule, which
* want the same three options for the same reasons.
* 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, so a changed recurrence rules it out); [allowSeries] drops the two wider
* options, for a rule whose days can't be recalculated from one moved
* occurrence — [reason] then says why.
* 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(

View File

@@ -40,6 +40,9 @@ 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. */
@@ -47,18 +50,15 @@ sealed interface MoveTarget {
/**
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
* A delta rather than a target date on purpose: the grid already knows how
* many columns the finger crossed, and re-deriving that from a date would
* mean the screen and this view model each resolving the event's first day
* in their own zone, which can disagree by a day.
* 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`), exactly as the detail and edit screens
* pass them, so a recurring series resolves the right occurrence.
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
*/
data class MoveRequest(
val eventId: Long,
@@ -69,9 +69,10 @@ data class MoveRequest(
/**
* A recurring drop waiting for the user to pick how far it reaches.
* [occurrenceOnly] means the rule names days that a single moved occurrence
* can't re-derive (`BYDAY=MO,WE`, `2TH`, …), so the only honest option left is
* this one occurrence — see [realignRecurrence].
* [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)
@@ -113,11 +114,9 @@ sealed interface MoveOutcome {
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
* edit screen's save uses, so recurring writes, reminder reconciliation and
* attendee preservation behave identically. Hosted at `CalendarHost` so one
* instance serves every calendar view and survives view switches.
*
* The full prefilled form is carried through the write — never a stripped one —
* because the occurrence-exception path reconciles reminders and attendees onto
* the new row, and a partial form would wipe them.
* 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(
@@ -137,27 +136,23 @@ class RescheduleViewModel @Inject constructor(
/**
* True from the moment a drop is accepted until its write settles — the
* window the dropped block holds its landing position for, rather than
* snapping back to where it came from until the grid re-reads it.
* 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. An undo moves an event exactly as a drop does, so the view that
* drew the drop takes this as its cue to carry the chip back rather than let
* it reappear on the old day. A counter rather than the undo itself: what a
* view needs is the *timing*, and it already knows what it moved.
* 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 landing inside that window would each compute
* their shift from the same pre-move occurrence, and the data layer applies
* both to the re-read anchor — so the shifts would compound.
* 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) {
@@ -167,8 +162,7 @@ class RescheduleViewModel @Inject constructor(
/**
* The calendars whose events may be dragged. Nothing below the UI guards
* this — the repository and data source attempt any write handed to them —
* so a block outside this set registers no drag gesture at all.
* 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() }
@@ -187,17 +181,19 @@ class RescheduleViewModel @Inject constructor(
val updated: EventForm,
/** True for a series master with a rule — an exception row is not one. */
val isRecurring: Boolean,
/**
* False when the rule names days this move can't re-derive, so anything
* wider than the single occurrence would leave rule and anchor disagreeing.
*/
/** 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,
)
fun move(request: MoveRequest) {
if (busy || _scopePrompt.value != null) return
/**
* 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)
@@ -214,13 +210,13 @@ class RescheduleViewModel @Inject constructor(
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
}
}
return true
}
/** Answer the scope dialog. */
fun moveWithScope(scope: RecurringWriteScope) {
val prepared = pending ?: return
// Belt and braces against the dialog ever offering a scope the rule
// can't carry: writing it would leave anchor and rule disagreeing.
// 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
@@ -238,13 +234,13 @@ class RescheduleViewModel @Inject constructor(
}
/**
* Put a completed move back where it came from. The outcome deliberately
* stands until the inverse write reports back: clearing it first would drop
* the confirmation chip and open a second one a moment later, rather than
* letting the one chip change what it says.
* 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) {
if (busy) return
fun undo(undo: MoveUndo): Boolean {
if (busy) return false
busy = true
_undoStarted.value += 1
viewModelScope.launch {
@@ -262,6 +258,7 @@ class RescheduleViewModel @Inject constructor(
}
busy = false
}
return true
}
/** Clear the outcome once the screen has shown it. */
@@ -288,13 +285,11 @@ class RescheduleViewModel @Inject constructor(
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
}
// A zero-distance drop is not a write. Matches the edit form's own
// pristine-form no-op, and keeps a mis-aimed long press harmless.
// A zero-distance drop is not a write.
if (shifted == original) return null
// The UNTIL check is deferred to the write: how far the move reaches
// decides which date has to clear it, and "only this event" writes an
// exception row that no UNTIL constrains at all.
// 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
@@ -304,11 +299,9 @@ class RescheduleViewModel @Inject constructor(
val isRecurring = original.rrule != null && !detail.isException
val movedDay = shifted.start.date != original.start.date
// The series anchor moves by the same *wall-clock* shift as the dragged
// occurrence, so only a whole-day shift moves it by a predictable number
// of days — a drag that also changes the time of day would carry some
// anchor times of day across an extra midnight and leave the rule naming
// the wrong weekday. All-day forms shift as bare dates, so they always
// qualify.
// 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) {
@@ -330,10 +323,14 @@ class RescheduleViewModel @Inject constructor(
// then is the single occurrence, whose exception row carries no rule.
updated = shifted.copy(rrule = realigned ?: original.rrule),
isRecurring = isRecurring,
canRealign = !isRecurring || !movedDay || realigned != null,
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 meaningful under [wholeDayShift], which is the
// only case a wider-than-one-occurrence write is offered in.
// 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),
)
@@ -382,14 +379,10 @@ class RescheduleViewModel @Inject constructor(
/**
* Whether this write would leave a rule whose `UNTIL` precedes the first day
* it now applies to — the provider then generates nothing and the event
* silently disappears from every view.
*
* Which date has to clear `UNTIL` depends on how far the write reaches: a
* whole-series move carries the series *anchor*, a split starts a new series
* at the moved occurrence, and a single occurrence becomes an exception row
* that no `UNTIL` constrains. Testing the occurrence in every case would
* refuse the perfectly ordinary drag of a bounded series' last occurrence.
* 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
@@ -403,6 +396,29 @@ class RescheduleViewModel @Inject constructor(
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
@@ -415,8 +431,7 @@ class RescheduleViewModel @Inject constructor(
/**
* Undo is offered only where the inverse is one symmetric write: a
* non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the
* −Δ wall-clock shift lands on the re-read anchor). "This event" leaves an
* 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.
*/
@@ -433,8 +448,7 @@ class RescheduleViewModel @Inject constructor(
/**
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: a UTC midnight for an all-day event, the real instant
* for a timed one.
* read dates back in: UTC midnight for an all-day event.
*/
private fun movedStartMillis(prepared: PreparedMove): Long {
val form = prepared.updated

View File

@@ -45,8 +45,8 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
/**
* The time shown in the timeline gutter while a block is dragged: 24h →
* "09:15", 12h → "9:15". The meridiem is dropped on purpose — the hour labels
* around it already carry it, and the gutter is too narrow to hold it.
* "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)

View File

@@ -79,10 +79,9 @@ data class TimelineDrag(
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
/**
* The timeline's live geometry, republished on every layout. Held in plain
* fields rather than snapshot state on purpose: it changes on every scroll
* frame, and recomposing the screen that often would cost far more than the
* drag loop's own per-frame read of it.
* 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. */
@@ -102,18 +101,15 @@ class TimelineGeometry {
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day
* in Arabic — the mapping has to flip with it.
* 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` a page change mid-drag would otherwise strand a ghost
* and the block it renders is drawn in an overlay: a `Card` column ends its
* modifier chain with a clip, so a block offset toward the neighbouring column
* would simply be cut off in place.
* `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 {
@@ -123,19 +119,16 @@ class TimelineDragController {
private set
/**
* A dropped block, held at the slot it landed on while the write runs. The
* grid behind it still shows the old time until the provider notifies and
* the query re-reads, so releasing the copy at drop time would snap the
* event back to where it came from for the length of the write.
* 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] on purpose: [drag] changes on every frame, and the blocks that
* only need to know "am I the ghost" must not recompose that often. Stays
* set through [settling], so the source never reappears under the copy.
* 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
@@ -143,10 +136,8 @@ class TimelineDragController {
/**
* Where the settled drop came from, and which event row it belongs to. The
* instance id alone can't identify the source block for the length of the
* write: the provider regenerates `Instances` rows, so a re-read that still
* carries the *old* time can arrive under a new instance id — and a source
* matched by instance id would stop being the ghost and flash back to full
* opacity in the slot the event is about to leave.
* 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)
@@ -156,9 +147,8 @@ class TimelineDragController {
/**
* Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true: releasing on a timer
* puts the source ghost back at full opacity in its *old* slot for whatever
* is left of the re-read — a flicker of the event where it no longer is.
* 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
@@ -168,10 +158,8 @@ class TimelineDragController {
private var pointer = Offset.Zero
/**
* The slot the block already occupied when it was picked up. A long press
* that never moves must write nothing — and because the target snaps to the
* grid, "nothing moved" is not the same as "the start is unchanged": an
* event at 09:07 resolves to 09:00 the instant it lifts.
* 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
@@ -205,9 +193,8 @@ class TimelineDragController {
/**
* Whether [block] is the one whose copy is in flight, and so must stay a
* ghost. While the finger holds it that is the instance it picked up; once
* dropped it is also whatever now sits in the slot it left, however the
* provider has renumbered it in the meantime.
* 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
@@ -221,8 +208,7 @@ class TimelineDragController {
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
* or its title: a single-occurrence move writes an exception row with a new
* `eventId`, which nothing else about the drop can predict.
* or its title, since a single-occurrence move writes a new `eventId`.
*/
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return
@@ -254,9 +240,8 @@ class TimelineDragController {
/**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still on screen dissolving. Un-ghosting only at [release]
* would leave the block under the fading copy dim, and brighten it once the
* copy was gone: a dip the eye reads as the event flickering.
* 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
@@ -286,10 +271,11 @@ class TimelineDragController {
val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
// Clamp the start into the target day; a tail running past midnight is
// fine and stays visible on the next day.
// Clamp the start into the target day; a tail past midnight is fine.
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val span = block.endMin - block.startMin
// The event's own length, not the block's: TimedBlock.endMin is clipped
// at midnight, which would draw a 22:0002: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
@@ -312,8 +298,8 @@ class TimelineDragController {
/**
* Hold the scroll for the whole drag and nudge it once per frame while the
* finger sits near an edge. One `scroll` call, not a loop of
* `animateScrollBy`: each of those re-acquires the mutex and cancels the last.
* 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
@@ -374,17 +360,15 @@ fun TimelineDrop.startInstant(zone: TimeZone): Instant =
const val SETTLE_GRACE_MILLIS: Long = 60L
/**
* How long to wait for a grid that never confirms the drop — the event landed on
* a day this timeline doesn't show, or the write failed and nothing changed. The
* copy has to go either way.
* 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, so the two overlap rather than one replacing the other. Kept
* short, because a full-width copy sits over any neighbour it now shares a lane
* with until it is gone.
* 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
@@ -409,9 +393,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll()
}
// Hold the landed copy until the write is done — including the whole time a
// 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, so the copy dissolves onto a block that is already there.
// drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
@@ -425,14 +409,13 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
Box(
modifier = modifier
.fillMaxSize()
// A copy of a block that is still in the tree behind it; announcing
// it again would just duplicate the event for the drag's duration.
// 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, so the release reads as the block settling rather than vanishing.
// 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,
@@ -445,8 +428,15 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
)
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:0002:00" and not "22:0024: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(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale)
formatMinuteOfDay(endMin, use24Hour, locale)
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware

View File

@@ -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)
}
}

View File

@@ -346,7 +346,7 @@ private fun DayContent(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
move?.move(
val took = move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -354,6 +354,9 @@ private fun DayContent(
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()
},
)
}
@@ -649,8 +652,7 @@ private fun DayColumnCard(
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// the rare column holding two occurrences of one series, which would
// otherwise be two blocks under one key.
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
@@ -723,8 +725,8 @@ private fun EventBlock(
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.
// 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,
@@ -738,8 +740,7 @@ private fun EventBlock(
val ghost = ghostAlpha(lifted)
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels,
// then fades out as the copy settles on its new slot.
// 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)

View File

@@ -28,8 +28,7 @@ class MonthRowGeometry(
val laneCount: Int,
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day
* in Arabic.
* 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. */
@@ -57,10 +56,9 @@ class MonthRowGeometry(
}
/**
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither
* id alone will do: the provider hands re-read instances new instance ids, and a
* single-occurrence move writes an exception row with a new event id — the title
* is what survives both.
* 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
@@ -83,14 +81,10 @@ data class MonthChipDrop(
)
/**
* Hoisted drag state for the month grid (#68).
*
* Every visible week row registers itself, so a chip can be carried across rows:
* the pointer node that took the press keeps receiving events after the finger
* has left its own bounds, and the target is then resolved against whichever
* registered row the finger is actually over. Rows are keyed by an identity token
* rather than by their date, because the continuous style can show the same week
* twice — once in each adjoining month.
* 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 {
@@ -100,19 +94,16 @@ class MonthDragController {
private set
/**
* A dropped chip, held on the day it landed on while the write runs. The grid
* behind it still shows the old day until the provider notifies and the query
* re-reads, so releasing at drop time would snap the event back for the
* length of the write.
* 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] on purpose: [drag] changes on every frame, while the chips and rows
* that only need "am I the ghost" / "must I stop clipping" must not. Stays
* set through [settling], so the source never reappears under the copy.
* [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
@@ -120,20 +111,16 @@ class MonthDragController {
/**
* Where the grid itself now draws the drop, in root coordinates — published
* by whichever week row ends up holding it. The copy is dropped wherever the
* finger was, but the grid seats it in a lane; without this the copy waits
* out a flat hold beside the chip the grid has already drawn and then
* cross-fades across the gap, which reads as the event taking its time to
* arrive. With it the copy glides onto its seat and hands over there.
* 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 at [release], for the same reason [liftedInstanceId]
* is: held to the end it would keep the chip dim under a copy that has
* already faded, and brighten it afterwards — a dip the eye reads as the
* event flickering.
* [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)
@@ -143,8 +130,7 @@ class MonthDragController {
/**
* The last drop this controller made, kept past [release] for [beginUndo] —
* the confirmation chip carries Undo for four seconds after the copy is long
* gone. Not snapshot state: nothing draws from it.
* the confirmation chip carries Undo long after the copy is gone.
*/
private var undoable: MonthChipDrag? = null
@@ -199,10 +185,6 @@ class MonthDragController {
/**
* Whether [days] holds the chip the grid has drawn for the settled drop —
* the seat the copy is on its way to, which must ghost until it gets there.
* Matched on the landing day plus either the event row or its title: a
* single-occurrence move writes an exception row with a new `eventId`, and
* the provider hands re-read instances new ids, so neither alone identifies
* the chip the drop became.
*/
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
val landed = settling?.takeIf { settledGhost } ?: return false
@@ -211,9 +193,8 @@ class MonthDragController {
}
/**
* Ask the row keyed [token] whether it now seats the settled chip, and take
* its answer. The continuous style can show the same week twice, once in each
* adjoining month, so both copies of the landing row will answer: the seat
* 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) {
@@ -227,15 +208,10 @@ class MonthDragController {
}
/**
* Send a copy back for an undo, which moves the event exactly as the drop did
* and so should read the same way rather than teleporting the chip. Nothing
* here writes anything: it puts a chip on the journey the inverse write is
* about to make, and the settle that follows is the drop's own.
*
* False when there is nothing to carry — no drop of this controller's to undo
* (it happened in another view, or has already been undone), or the grid does
* not seat the moved event where it would have to start from. The chip then
* simply reappears on the day it came from, as it always did.
* 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
@@ -250,15 +226,16 @@ class MonthDragController {
}
/**
* End the drag. A real drop keeps its chip on the target day as [settling]
* until [release] — slid over to that day's column, so it lands where the
* grid is about to draw it rather than wherever the finger happened to be.
* 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 ?: return null
val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null
settling = landed.copy(
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
)
@@ -270,8 +247,7 @@ class MonthDragController {
/**
* Stop ghosting the source while the copy is still dissolving — un-ghosting
* only at [release] leaves the chip under it dim and then brightens it, a
* dip the eye reads as the event flickering.
* at [release] instead leaves the chip dim and then brightens it.
*/
fun handOver() {
liftedInstanceId = null
@@ -312,8 +288,8 @@ class MonthDragController {
drag = MonthChipDrag(
event = event,
grabDate = grabbed,
// Off the grid entirely (the gutter, the header): keep the last cell
// it was over, so a wobble past the edge doesn't drop the target.
// 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,

View File

@@ -415,12 +415,10 @@ fun MonthScreen(
},
) { innerPadding ->
// Hoisted above every style's grid: a dragged chip is drawn in an
// overlay so it is free of the week row's clip and, in the scrolling
// styles, of the list viewport's.
// 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 rather than
// reappearing on its old day. The signal comes from the write, not
// from the chip that offers it: that lives above every view.
// 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
@@ -477,8 +475,7 @@ fun MonthScreen(
/**
* How long to wait for a grid that never seats the drop — it landed in a day's
* "+N" overflow, or on a day this month doesn't show, or the write failed and
* nothing changed. The copy has to go either way.
* "+N" overflow or on a day this month doesn't show, or the write failed.
*/
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
@@ -493,11 +490,10 @@ private fun MonthDragOverlay(controller: MonthDragController) {
// Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate)
// Hold the landed chip until the write is done — including the whole time a
// 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: a chip is dropped at whatever height the finger was at,
// while the grid seats it in a lane, so the copy has a gap to close before
// the two are the same chip and the hand-over can be invisible.
// 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) }
@@ -522,14 +518,13 @@ private fun MonthDragOverlay(controller: MonthDragController) {
Box(
modifier = Modifier
.fillMaxSize()
// A copy of a chip that is still in the tree behind it; announcing it
// again would just duplicate the event for the drag's duration.
// 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, so the release reads as the chip settling rather than vanishing.
// 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",
@@ -1858,10 +1853,9 @@ private fun MonthWeekRow(
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight()
// Drag to reschedule (#68). The chips can take no pointer input of their own
// the full-bleed tap layer below sits on top of them, and Compose stops
// sibling hit-testing at the topmost hit — so one detector on the row's day-
// column box hit-tests them geometrically instead.
// 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() }
@@ -1874,10 +1868,9 @@ private fun MonthWeekRow(
DisposableEffect(rowToken, dragController) {
onDispose { dragController?.removeRow(rowToken) }
}
// Republished on layout *and* on every recomposition: the coordinates only
// change on the former, but what the row draws — which the controller reads
// to find a moved chip's seat — changes on the latter, and a re-read that
// moves an event doesn't move the row it lands in.
// 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) {
@@ -1898,9 +1891,8 @@ private fun MonthWeekRow(
}
SideEffect { publish() }
// Once the grid holds the settled chip, tell the controller where this row
// has seated it, so the copy in flight can glide onto it. Keyed on the week
// because that is what changes when the re-read lands: until then the row
// still draws the event on the day it came from and answers nothing.
// 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)
}
@@ -2003,8 +1995,8 @@ private fun MonthWeekRow(
bandCoordinates[0] = it
publish()
}
// A dragged chip travels to another row, so the clip has
// to yield for it exactly as it does for a morph.
// 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.
@@ -2167,10 +2159,9 @@ private fun MonthWeekRow(
}
/**
* The row-level pickup for month chips: it resolves which chip the press landed
* on from the geometry the row just laid out, and abandons the gesture when the
* press was on empty space (or on an event whose calendar can't be moved), so
* tapping a day still opens it.
* 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(
@@ -2216,19 +2207,19 @@ private fun monthChipDragModifier(
onDrop = {
controller?.finish()?.let { drop ->
// How many columns the finger crossed — grabbing the middle of a
// multi-day bar shifts the event by what the finger travelled, not
// to where it landed.
// multi-day bar shifts by what it travelled, not to where it landed.
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
if (delta != 0) {
moveScope?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.ByDays(delta),
),
)
}
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() },
@@ -2328,10 +2319,8 @@ private fun MonthBar(
modifier: Modifier = Modifier,
/**
* The days this chip covers in its row, for the drag (#68). A drop's own chip
* has to ghost like the source it came from until the copy has landed on it,
* and the instance id can't find it: the provider hands the re-read instance a
* new one, so the chip the drop became would otherwise sit at full opacity
* under the copy still travelling towards it — the event drawn twice.
* 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,
) {
@@ -2341,8 +2330,7 @@ private fun MonthBar(
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels, then
// fades out as the copy settles on its new day.
// 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

View File

@@ -69,9 +69,6 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
/**
* The event drawn in lane [lane] of column [col], or null for an empty slot.
*
* Mirrors how the paged grid seats a week: spanning bars keep the lane the row
* layout gave them, and the day's timed pills fill the slots those bars leave.
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
* this point", so an empty lane above a seated bar has to stay empty (#68).
*/

View File

@@ -349,8 +349,8 @@ private fun WeekContent(
// gestures coexist without fighting.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent on purpose: a page change mid-drag would strand
// the floating block inside the outgoing page.
// 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() }
@@ -382,7 +382,7 @@ private fun WeekContent(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = { drop ->
move?.move(
val took = move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -390,6 +390,9 @@ private fun WeekContent(
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()
},
)
}
@@ -711,8 +714,7 @@ private fun Timeline(
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset — a drag maps
// through it without reading the scroll state.
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
val gap = with(density) { COLUMN_GAP.toPx() }
dragController.geometry.let {
@@ -797,8 +799,7 @@ private fun DayColumnCard(
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// the rare column holding two occurrences of one series, which would
// otherwise be two blocks under one key.
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
@@ -892,8 +893,8 @@ private fun EventBlock(
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.
// 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,
@@ -907,8 +908,7 @@ private fun EventBlock(
val ghost = ghostAlpha(lifted)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels,
// then fades out as the copy settles on its new slot.
// 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)

View File

@@ -32,7 +32,7 @@
<string name="month_prev">الشهر السابق</string>
<string name="month_next">الشهر القادم</string>
<string name="month_today_action">اليوم</string>
<string name="month_action_settings">إعدادات</string>
<string name="month_action_settings">الإعدادات</string>
<string name="settings_title">الإعدادات</string>
<string name="settings_theme">الثيم</string>
<string name="settings_theme_system">النظام</string>
@@ -137,7 +137,7 @@
<string name="event_detail_calendar">التقويم</string>
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
<string name="event_detail_description">الوصف</string>
<string name="event_detail_all_day">كل يوم</string>
<string name="event_detail_all_day">طوال اليوم</string>
<string name="event_detail_location">الموقع</string>
<string name="event_detail_attendees">الحضور</string>
<string name="event_detail_recurrence">التكرار</string>
@@ -219,8 +219,8 @@
<string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
<string name="settings_default_view">طريقة العرض الافتراضية</string>
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
<string name="settings_soften_colors">ألوان تقويم متناسقة</string>
<string name="settings_soften_colors_summary">حافظ على درجة لون كل تقويم لكن قم بتسوية سطوعه، بحيث كل حدث يظل مقروءًا و تتناسق الألوان معًا. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
<string name="settings_font_headings">خط العناوين</string>
<string name="settings_font_body">خط النص</string>
<string name="settings_font_system">افتراضي النظام</string>
@@ -309,4 +309,237 @@
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
<string name="settings_reminders">تذكيرات الحدث</string>
<string name="settings_default_reminder">التذكير الافتراضي</string>
<string name="reminder_custom_set">تعيين</string>
<string name="settings_autofocus_title_hint">عند بدء حدث جديد، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
<string name="settings_section_event_form">نموذج حدث جديد</string>
<string name="settings_quick_switch_header">زر التبديل السريع</string>
<string name="app_name">التقويم</string>
<string name="settings_theme_system_summary">الحالي %1$s</string>
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
<string name="settings_hour_lines">خطوط الساعات</string>
<string name="settings_hour_lines_summary">إظهار خط فاصل في كل ساعة في طريقة عرض الأسبوع واليوم</string>
<string name="timeline_scale_fit_day_summary">جميع الـ 24 ساعة على شاشة واحدة، لا تمرير</string>
<string name="timeline_scale_custom">مخصص</string>
<string name="settings_widget_size_small">صغير</string>
<string name="settings_widget_size_medium">متوسط</string>
<string name="settings_widget_size_large">كبير</string>
<string name="settings_widget_size_extra_large">كبير جدًا</string>
<plurals name="agenda_range_days">
<item quantity="zero">(%d) لا أيام</item>
<item quantity="one">(%d) يوم واحد</item>
<item quantity="two">%d يومان</item>
<item quantity="few">%d أيام</item>
<item quantity="many">%d يوم</item>
<item quantity="other">%d يوم</item>
</plurals>
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة</string>
<string name="settings_agenda_range_bar">شريط النطاق</string>
<string name="settings_month_header">عرض الشهر</string>
<string name="month_style_continuous">تمرير الشهور</string>
<string name="month_style_dense">أسابيع سلسة</string>
<string name="month_style_continuous_summary">كل شهر يقع تحت عنوانه الخاص، مع مساحة صغيرة تميزه عن الشهر التالي.</string>
<string name="month_style_dense_summary">تمر الأسابيع دون انقطاع، ويتدفق كل شهر مباشرة إلى الشهر التالي دون وجود فجوة بينهما.</string>
<string name="month_split_no_events">لا شيء مجدول</string>
<string name="month_style_split_summary">شبكة مدمجة تحدد الأيام التي تتضمن أحداثًا، ويتم إدراج اليوم الذي تنقر عليه تحتها.</string>
<string name="settings_autofocus_title">تركيز العنوان على حدث جديد</string>
<string name="settings_event_duration">المدة الافتراضية</string>
<string name="settings_event_duration_hint">مدة الحدث الجديد تبقى كما هي حتى أنت تغيير وقت نهايته. أما أحداث التي هي طوال اليوم فلن تتأثر.</string>
<string name="settings_section_notifications">الإشعارات</string>
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
<string name="settings_group_look">المظهر و السلوك</string>
<string name="settings_group_data">البيانات</string>
<string name="settings_group_app">التطبيق</string>
<string name="settings_language">لغة التطبيق</string>
<string name="settings_dynamic_color_summary">أخذ ألوان التطبيق من خلفيتك.</string>
<string name="settings_group_about">حول</string>
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
<string name="settings_about_source">المصدر</string>
<string name="settings_about_privacy">سياسة الخصوصية</string>
<string name="settings_about_support">دَعم التطوير</string>
<string name="settings_about_version">الإصدار %1$s</string>
<string name="settings_about_logo_desc">رمز تطبيق Calendula</string>
<string name="crash_report_body_template">شكرًا لإبلاغ عن عطل في %1$s. يُرجى إضافة أي شيء تتذكره حول ما كنت تفعله، ثم إرسال.\n\n### ماذا حدث\n\n\n### تقرير العطل\n%2$s\n</string>
<string name="settings_section_about">حول</string>
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
<string name="settings_report_problem_hint">أرسل تقرير الأعطال أو افتح متعقب المشكلات</string>
<string name="settings_language_auto">افتراضي النظام</string>
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
<string name="settings_special_dates_enable">إظهار تواريخ جهات الاتصال</string>
<string name="settings_special_dates_enable_hint">اعكس أعياد ميلاد جهات اتصالك والتواريخ الأخرى إلى التقويمات المحلية. يقرأ جهات الاتصال على هذا الجهاز فقط — لا يتم رفع أي شئ، وجهات اتصالك لا يتم تغييرها أبدًا.</string>
<string name="settings_section_language">اللغة</string>
<string name="qs_tile_new_event_label">حدث جديد</string>
<string name="shortcut_new_event_long">إنشاء حدث جديد</string>
<string name="shortcut_new_event_short">حدث جديد</string>
<string name="settings_qs_tile_hint">أضف مربع ”حدث جديد“ إلى لوحة الإعدادات السريعة.</string>
<string name="calendars_local_header">تقويماتك</string>
<string name="settings_section_special_dates">تواريخ جهات الاتصال الخاصة</string>
<string name="calendars_title">التقويمات</string>
<string name="calendars_add">إضافة تقويم</string>
<string name="settings_section_calendars">التقويمات</string>
<string name="settings_manage_calendars">إدارة التقويمات</string>
<string name="event_edit_recurrence_next">التالي: %1$s</string>
<plurals name="reminder_minutes">
<item quantity="zero">(%d) لا دقائق قبل</item>
<item quantity="one">(%d) دقيقة واحده قبل</item>
<item quantity="two">%d دقيقتان قبل</item>
<item quantity="few">%d دقائق قبل</item>
<item quantity="many">%d دقيقة قبل</item>
<item quantity="other">%d دقيقة قبل</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="zero">(%d) لا ساعات قبل</item>
<item quantity="one">(%d) ساعة واحده قبل</item>
<item quantity="two">%d ساعتان قبل</item>
<item quantity="few">%d ساعات قبل</item>
<item quantity="many">%d ساعة قبل</item>
<item quantity="other">%d ساعة قبل</item>
</plurals>
<plurals name="reminder_days">
<item quantity="zero">(%d) لا أيام قبل</item>
<item quantity="one">(%d) يوم واحد قبل</item>
<item quantity="two">%d يومان قبل</item>
<item quantity="few">%d أيام قبل</item>
<item quantity="many">%d يوم قبل</item>
<item quantity="other">%d يوم قبل</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="zero">(%d) لا أسابيع قبل</item>
<item quantity="one">(%d) أسبوع واحد قبل</item>
<item quantity="two">%d أسبوعان قبل</item>
<item quantity="few">%d أسابيع قبل</item>
<item quantity="many">%d أسبوع قبل</item>
<item quantity="other">%d أسبوع قبل</item>
</plurals>
<string name="reminder_benefit_reversible_body">يوجد المفتاح في الإعدادات، ضمن الإشعارات.</string>
<string name="reminder_custom_amount">القيمة</string>
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
<string name="settings_snooze_duration">مدة التأجيل</string>
<string name="settings_calendar_reminder_inherits">الافتراضي (%1$s)</string>
<string name="settings_translate">المساعدة في الترجمة</string>
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
<string name="settings_appearance_subtitle">الثيم، الألوان، الخطوط</string>
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و احتفالات ذكرى</string>
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
<string name="settings_license">الترخيص</string>
<string name="settings_special_dates_disable_title">إيقاف تشغيل تواريخ جهات الاتصال؟</string>
<string name="settings_special_dates_disable_confirm">إيقاف</string>
<string name="settings_special_dates_sync_now">زامن الآن</string>
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات.</string>
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
<string name="calendars_enable_all">تفعيل الكل</string>
<string name="calendars_disable_all">تعطيل الكل</string>
<string name="calendars_add_account">إضافة حساب</string>
<string name="calendars_new_title">تقويم جديد</string>
<string name="calendars_edit_title">تعديل التقويم</string>
<string name="calendars_name_label">الاسم</string>
<string name="calendars_color_label">اللون</string>
<string name="calendars_description_hint">أضف وصفًا</string>
<string name="calendars_delete_confirm_title">حذف التقويم؟</string>
<string name="calendars_delete_confirm_message">\"%1$s\" وجميع أحداثه ستتم إزالتها نهائيًا من هذا الجهاز.</string>
<string name="calendars_write_error">تعذّر حفظ التغيير.</string>
<string name="calendars_backup_header">النسخ الاحتياطي</string>
<string name="calendars_backup_hint">التقويمات المحلية لا تتم مزامنتها في أي مكان، لذا قم بتصديرها إلى ملف .ics للاحتفاظ بنسخة.</string>
<string name="dialog_save">حفظ</string>
<string name="settings_special_dates_grant">منح الوصول</string>
<string name="calendars_backup_action">تصدير كملف .ics</string>
<string name="calendars_export_title">تصدير التقويمات</string>
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في .ics الملف.</string>
<string name="calendars_export_action">تصدير</string>
<string name="calendars_restore_header">استعادة</string>
<string name="calendars_restore_action">استعادة من ملف .ics</string>
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ .ics الملف.</string>
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
<string name="calendars_auto_backup_every">كل %1$s</string>
<string name="calendars_auto_backup_interval_min">الحد الأدني ٣٠ دقيقة.</string>
<string name="calendars_auto_backup_status_never">لا نسخ احتياطي تلقائي بعد</string>
<string name="calendars_auto_backup_status_ok">آخر نسخة احتياطية: %1$s</string>
<string name="calendars_auto_backup_status_failed">فشل آخر نسخ احتياطي: %1$s</string>
<string name="backup_channel_name">النسخ الاحتياطي</string>
<string name="backup_channel_description">يحذّر إذا تفشل النسخ الاحتياطي التلقائي بشكل متكرر.</string>
<string name="backup_failed_title">فشل النسخ الاحتياطي التلقائي</string>
<string name="backup_failed_text">Calendula تعذَّر في كتابة ملف النسخ الاحتياطي. تحقق من مجلد النسخ الاحتياطي في الإعدادات.</string>
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
<plurals name="calendars_backup_done">
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
<item quantity="one">تم تصدير (%d) حدث واحد.</item>
<item quantity="two">تم تصدير %d حدثان.</item>
<item quantity="few">تم تصدير %d أحداث.</item>
<item quantity="many">تم تصدير %d حدث.</item>
<item quantity="other">تم تصدير %d حدث.</item>
</plurals>
<string name="import_title">استيراد الأحداث</string>
<string name="import_target_header">إضافة إلى تقويم</string>
<string name="import_empty">لم يتم العثور على أحداث في هذا الملف.</string>
<string name="import_failed">تعذَّر قراءة هذا الملف.</string>
<string name="import_no_calendar">لا تقويم قابل للكتابة لاستيراد إليه. أنشئ تقويمًا محليًا أولاً.</string>
<string name="import_done_title">اكتمل الاستيراد</string>
<string name="import_done_dedup_note">تم تخطي الأحداث الموجودة بالفعل في التقويم.</string>
<string name="settings_special_dates_reminders">التذكيرات</string>
<string name="calendars_account_menu_a11y">المزيد من الخيارات لـ %1$s</string>
<string name="calendars_synced_hint">تأتي هذه من الحسابات الموجودة على جهازك. يمكنك إنشاؤها وتعديلها في تطبيقها الخاص.</string>
<string name="calendars_synced_header">التقويمات المتزامنة</string>
<string name="calendars_local_empty">لا تقويمات محلية حتى الآن. أنشئ واحدًا للاحتفاظ بالأحداث على هذا الجهاز فقط.</string>
<string name="calendars_auto_backup_interval">الفاصل الزمني</string>
<string name="import_warning_no_start">تم تخطي حدث بدون وقت بدء.</string>
<string name="import_warning_recurrence">تم تخطي بعض التكرارات التي تم تغييرها للأحداث المتكررة.</string>
<string name="import_close">إغلاق</string>
<string name="import_done_skipped_label">التكرارات</string>
<string name="import_done_added_label">أُضيف</string>
<string name="crash_dialog_title">%1$s تعطل</string>
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
<string name="import_button">استيراد</string>
<string name="crash_dialog_dismiss">ليس الآن</string>
<string name="crash_report_copied">تم نسخ التقرير إلى الحافظة</string>
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك.</string>
<string name="crash_report_issue_title">تقرير العطل</string>
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_</string>
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
<string name="event_access_public_summary">يري كل شخص لديه حق الوصول التفاصيل الكاملة</string>
<string name="event_access_private_summary">الآخرون يرون فقط أنك مشغول</string>
<string name="event_access_default_summary">أينما كان ما يفعله هذا التقويم عادةً</string>
<string name="month_split_collapse">عرض أحداث اليوم</string>
<string name="month_split_expand">إظهار الشهر بأكمله</string>
<string name="settings_week_day_header">الأسبوع و اليوم</string>
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز.</string>
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
<string name="calendars_visibility_notice_message">Calendula يعرض الآن التقويمات التي تم تشغيلها لهذا الجهاز، لذلك ما تراه وما يذكرك لم يعد بإمكانه أن يختلف. بعض تقويماتك متوقفة حاليًا — تم إيقافها هنا أو في تطبيق تقويم آخر. أعد تشغيل أي منها في الإعدادات ← التقويمات.</string>
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
<string name="calendars_state_read_only">للقراءة فقط</string>
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
<string name="duration_custom_max">كحد أقصى %1$s</string>
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
<string name="reminder_benefit_delivery_title">التذكيرات، تُسلَّم</string>
<string name="reminder_use_default">استخدام التذكير الافتراضي</string>
<string name="settings_timeline_scale">ارتفاع الساعة</string>
<string name="settings_timeline_scale_hint">كم مدى المساحة العمودية التي تأخذها الساعة الواحده في عرض الأسبوع واليوم. كلا العرضين يشتركا في هذا الإعداد. يمكنك أيضًا الضغط على المخطط الزمني بإصبعين لتعيين أي ارتفاع بين.</string>
<string name="timeline_scale_fit_day">الملائمة لليوم بأكمله</string>
<string name="timeline_scale_compact">مضغوط</string>
<string name="timeline_scale_regular_summary">المساحة القياسية</string>
<string name="timeline_scale_comfortable">مُريَّح</string>
<string name="timeline_scale_compact_summary">المزيد من الساعات لكل شاشة، ومجموعات أصغر</string>
<string name="timeline_scale_regular">العادي</string>
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
</resources>

View File

@@ -69,7 +69,7 @@
<!-- Drag an event to another time or day (#68) -->
<string name="event_move_action">Move…</string>
<string name="event_move_recurring_title">Move recurring event</string>
<string name="event_move_occurrence_only">This series picks its days in a way that can\'t be recalculated from one moved event, so only this event can move.</string>
<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>

View File

@@ -288,9 +288,8 @@ class EventWriteMapperTest {
@Test
fun `a weekday move must carry the rule with the anchor, or the series stays put`() {
// The anchor moves by the same shift as the occurrence, so a Monday
// series lands on a Wednesday — while RRULE is written verbatim. Without
// realignRecurrence the rule would still say Monday and nothing moves.
// 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)),

View File

@@ -108,8 +108,7 @@ class CalendarRowStateTest {
@Test
fun `a switched-off calendar's events stay movable, unlike a new-event target`() {
// Nothing renders them, so the question is moot — but the predicate is
// deliberately not isEventTarget, which would also exclude them.
// The predicate is deliberately not isEventTarget.
val hidden = cal().copy(isVisibleInSystem = false)
assertThat(hidden.isEventTarget).isFalse()
assertThat(hidden.allowsEventMove).isTrue()

View File

@@ -67,8 +67,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
// Two real hours from 01:00 CET lands at 04:00 CEST, not 03:00: the wall
// clock stretches because the hour in between does not exist.
// 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))
@@ -131,9 +131,8 @@ class EventShiftTest {
@Test
fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() {
// 22:00 Sat -> 04:00 Sun is six real hours; the target Sunday is the one
// Berlin springs forward on. Keeping wall clock would write a five-hour
// DURATION for the whole series.
// 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)),

View File

@@ -26,8 +26,7 @@ class RecurrenceRealignTest {
fun `a day-of-month rule is refused, because the anchor moves by days not dates`() {
// BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1:
// the rebuilt rule would say the 1st while the anchor became Jan 29 — a
// DTSTART that is not an instance of its own rule. Weekday arithmetic is
// uniform mod 7 and survives the same shift; day-of-month is not.
// 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)),
@@ -36,8 +35,7 @@ class RecurrenceRealignTest {
@Test
fun `BYDAY on a non-weekly rule is refused`() {
// "every Monday of the month" is a shape this has not been reasoned about,
// and parseSimpleRecurrence can't read it either — so the UNTIL guard
// 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()
}
@@ -86,8 +84,7 @@ class RecurrenceRealignTest {
@Test
fun `everything realignable is also a rule the UNTIL guard can read`() {
// problems() checks UNTIL through parseSimpleRecurrence; a rule this
// realigns but that parser rejects would move a series past its own end
// unchecked. Accepting only weekly BYDAY keeps the two in step.
// realigns but that parser rejects would go unchecked.
val realignable = listOf(
"FREQ=WEEKLY;BYDAY=MO",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z",

View File

@@ -45,10 +45,8 @@ class RescheduleViewModelTest {
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
// Monday 2026-06-08, midday in whatever zone the test JVM runs in — resolved
// in the device zone rather than UTC, because that is the zone the drop path
// reads dates back in, and at UTC+13 a UTC midday is already Tuesday. The
// BYDAY assertions below depend on this really being a Monday.
// 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())
@@ -69,11 +67,12 @@ class RescheduleViewModelTest {
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(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
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,
@@ -234,6 +233,64 @@ class RescheduleViewModelTest {
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,
@@ -282,8 +339,7 @@ class RescheduleViewModelTest {
vm.move(toWednesday())
advanceUntilIdle()
// The block belongs to the write, not to the drop: an exception row is
// constrained by no UNTIL, so this scope must still be on offer.
// An exception row is constrained by no UNTIL, so the scope stays on offer.
assertThat(vm.scopePrompt.value).isNotNull()
vm.moveWithScope(RecurringWriteScope.ThisEvent)
@@ -302,9 +358,8 @@ class RescheduleViewModelTest {
}
val vm = viewModel(tempDir, fake)
// Onto the next day *and* two hours later: the anchor moves by the same
// wall-clock shift, so an anchor late enough in the day would cross two
// midnights and land on a weekday the rebuilt rule doesn't name.
// 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,

View File

@@ -129,15 +129,29 @@ Two things the drag has to get right that the edit screen sidesteps:
from the edit screen; wiring it there too is a separate change.
What the rule has to agree with is the **series anchor**, not the occurrence
being dragged, and the anchor moves by the same *wall-clock* shift. Only two
things survive that intact, which is exactly the envelope the realigner accepts:
weekday (uniform mod 7, so every anchor time of day crosses the same number of
midnights) and a shift that is a whole number of days (otherwise a late-enough
anchor crosses one midnight more than the occurrence did). Day-of-month is not
uniform — `BYMONTHDAY=28` with a January anchor, occurrence Feb 28 dragged to
Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1` rule, a DTSTART that
is not an instance of its own rule and a phantom occurrence on any client that
trusts it. Those drags may only move the one occurrence.
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
@@ -157,6 +171,14 @@ 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**
@@ -204,8 +226,8 @@ already bounded by the same dirty check — only `ALL_DAY`, `EVENT_TIMEZONE`,
`DTSTART` and `DTEND`/`DURATION`/`RRULE` are written — so a concurrent remote
edit to the title, notes or guests survives untouched. What a drop *can* clobber
is a concurrent remote **time** change, and parking a one-gesture action behind a
modal would cost more than that case is worth; the undo in the confirmation
snackbar is the answer instead. Undo restores semantics, not the row's byte
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