diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt
index 84a810a..07b2244 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt
@@ -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),
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt
index 2bed934..b44ef8d 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt
@@ -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()))
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt
index 003506b..13f2037 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt
@@ -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) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 52c5050..baba48c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -97,6 +97,8 @@
Moving the whole series would change which days it falls on, so only this event can move.
Moved to %1$s
+ Changed to %1$s
+ Change recurring event
Undo
Move undone
Couldn\'t move the event
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt
index 53c96ca..a626b7a 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt
@@ -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)
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt
index a22c6f2..27731ed 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt
@@ -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))
+ }
}