Let the reschedule path resize events (#141)

This commit is contained in:
2026-10-09 10:51:17 +02:00
parent 549ef1dfbf
commit 4747926e27
6 changed files with 188 additions and 5 deletions
@@ -51,3 +51,17 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
}
/**
* The form with its start and end set independently — a timeline resize, which
* drags one edge and leaves the other where it stands. Each edge is resolved in
* the form's own zone, as [shiftedTo] does. All-day events are returned untouched.
*/
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
return copy(
start = newStart.toLocalDateTime(zone),
end = newEnd.toLocalDateTime(zone),
)
}
@@ -58,7 +58,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
prompt?.let { pending ->
RecurringScopeDialog(
title = stringResource(R.string.event_move_recurring_title),
title = stringResource(
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
),
onSelect = viewModel::moveWithScope,
onDismiss = viewModel::cancelScope,
allowSeries = !pending.occurrenceOnly,
@@ -69,9 +71,16 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val resizedLabel = moved?.endMillis?.let { end ->
"$movedLabel–${formatTime(end, use24Hour, locale)}"
}
val message = when (outcome) {
null -> null
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
is MoveOutcome.Moved -> if (resizedLabel != null) {
stringResource(R.string.event_resize_done, resizedLabel)
} else {
stringResource(R.string.event_move_done, movedLabel.orEmpty())
}
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
@@ -142,3 +151,8 @@ private fun formatMovedTo(
return localizedDateFormatter(locale, skeleton)
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
}
/** "10:30" — the end of a resized event, read in the device zone like its start. */
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
localizedDateFormatter(locale, if (use24Hour) "Hm" else "hm")
.format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resizedTo
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
@@ -54,6 +55,9 @@ sealed interface MoveTarget {
* disagree by a day over which zone the event's first day is resolved in.
*/
data class ByDays(val days: Int) : MoveTarget
/** New start and end instants — a timeline resize, which changes the length. */
data class Span(val start: Instant, val end: Instant) : MoveTarget
}
/**
@@ -74,7 +78,7 @@ data class MoveRequest(
* 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)
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
/** The inverse of a completed move, for the undo action. */
data class MoveUndo(
@@ -88,12 +92,14 @@ data class MoveUndo(
sealed interface MoveOutcome {
/**
* Written. [startMillis] is where the dragged occurrence now begins, for the
* confirmation message; [undo] is null when the write has no clean inverse.
* confirmation message, and [endMillis] where it ends — set only for a resize,
* whose message names both; [undo] is null when the write has no clean inverse.
*/
data class Moved(
val startMillis: Long,
val isAllDay: Boolean,
val undo: MoveUndo?,
val endMillis: Long? = null,
) : MoveOutcome
data object Undone : MoveOutcome
@@ -226,7 +232,10 @@ class RescheduleViewModel @Inject constructor(
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span,
)
}
}
return true
@@ -313,6 +322,7 @@ class RescheduleViewModel @Inject constructor(
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
is MoveTarget.Span -> original.resizedTo(target.start, target.end, zone)
}
// A zero-distance drop is not a write.
if (shifted == original) return null
@@ -396,6 +406,7 @@ class RescheduleViewModel @Inject constructor(
startMillis = movedStartMillis(prepared),
isAllDay = prepared.updated.isAllDay,
undo = undoFor(prepared, scope),
endMillis = resizedEndMillis(prepared),
)
} catch (e: CancellationException) {
throw e
@@ -484,6 +495,13 @@ class RescheduleViewModel @Inject constructor(
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event.
*/
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
}
private fun movedStartMillis(prepared: PreparedMove): Long {
val form = prepared.updated
return if (form.isAllDay) {
+2
View File
@@ -97,6 +97,8 @@
<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_resize_done">Changed to %1$s</string>
<string name="event_resize_recurring_title">Change recurring event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>
<string name="event_move_failed">Couldn\'t move the event</string>
@@ -152,4 +152,49 @@ class EventShiftTest {
val original = form()
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
}
@Test
fun `resizing sets each edge on its own`() {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
assertThat(resized.start).isEqualTo(original.start)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
}
@Test
fun `a resize past midnight carries the end onto the next day`() {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
}
@Test
fun `a pinned event is resized in its own zone`() {
val original = form(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
timezone = "America/New_York",
)
// "15:30 Berlin" is 09:30 in New York.
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
val resized = original.resizedTo(start, original.end.toInstant(newYork), berlin)
assertThat(resized.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(resized.end).isEqualTo(original.end)
}
@Test
fun `an all-day event ignores a resize`() {
val allDay = form(isAllDay = true)
val start = allDay.start.toInstant(berlin)
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
}
}
@@ -115,6 +115,17 @@ class RescheduleViewModelTest {
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
)
/** The end dragged an hour later, the start left where it is. */
private fun endOneHourLater() = MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(
Instant.fromEpochMilliseconds(beginMillis),
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
),
)
@Test
fun `a one-off drop writes straight through with no scope prompt`(
@TempDir tempDir: Path,
@@ -595,4 +606,83 @@ class RescheduleViewModelTest {
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandoned.value).isEqualTo(0)
}
@Test
fun `a resize writes the new length and reports both ends`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(endOneHourLater())
advanceUntilIdle()
val (_, original, updated) = fake.updatedEvents.single()
assertThat(updated.start).isEqualTo(original.start)
assertThat(updated.end).isNotEqualTo(original.end)
val moved = vm.outcome.value as MoveOutcome.Moved
assertThat(moved.startMillis).isEqualTo(beginMillis)
assertThat(moved.endMillis).isEqualTo(endMillis + 3_600_000L)
assertThat(moved.undo).isNotNull()
}
@Test
fun `a moved event reports no end, so its chip keeps the move wording`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat((vm.outcome.value as MoveOutcome.Moved).endMillis).isNull()
}
@Test
fun `resizing a recurring event asks for the scope, as a resize`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
vm.move(endOneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = false, resize = true))
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
// The start stays put, so the rule needs no realigning.
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
@Test
fun `pulling a recurring event's start back over midnight resizes only the occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
val sundayEvening = LocalDateTime(LocalDate(2026, 6, 7), LocalTime(22, 0))
.toInstant(TimeZone.currentSystemDefault())
vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
),
)
advanceUntilIdle()
// The start changed day but not by a whole number of days, so the
// series anchor can't follow it.
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
}
}