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