diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2907953..2247f05 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -412,6 +412,23 @@ android:resource="@xml/appwidget_info_today" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 335accf..3072061 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -447,6 +447,51 @@ No upcoming events Calendula today Nothing left today + Calendula countdown + Now + Ending now + Tomorrow + Ends tomorrow + + Until %1$s + + in %d min + in %d min + + + in %d h + in %d h + + + in %d day + in %d days + + + ends in %d min + ends in %d min + + + ends in %d h + ends in %d h + + + ends in %d day + ends in %d days + + Tap to choose an event to count down to + + %1$s is over + Count down to + Next event + Chosen event + The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped. + Event + None chosen yet + Search events + No events in the coming year match. + Couldn\'t load your events. + + %1$s · repeats Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/xml/appwidget_info_countdown.xml b/app/src/main/res/xml/appwidget_info_countdown.xml new file mode 100644 index 0000000..e1a1071 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_countdown.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt new file mode 100644 index 0000000..285e9ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt @@ -0,0 +1,89 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventResponse +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class CountdownTest { + + private val zone = TimeZone.UTC + private val now = Instant.parse("2026-10-21T12:00:00Z") + + private fun event( + start: Instant, + end: Instant = start + 1.hours, + id: Long = 1, + allDay: Boolean = false, + response: EventResponse = EventResponse.Going, + ) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response, + ) + + @Test + fun `labels step from days to hours to minutes to now`() { + assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0)) + assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25)) + assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3)) + assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1)) + // Tomorrow 23:00 is 35 h away but one calendar day. + assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1)) + assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12)) + } + + @Test + fun `a running event counts to its end`() { + val running = event(start = now - 20.minutes, end = now + 10.minutes) + assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10)) + } + + @Test + fun `the next redraw lands where the label changes`() { + // "in 25 min" turns into "in 24 min" a minute later. + assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes) + // "in 3 h" (2 h 40 min) holds until 2 h 30 min are left. + assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes) + // "in 1 h" holds until the last hour, then counts minutes. + assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes) + // Days change at midnight. + assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z")) + } + + @Test + fun `next mode skips all-day, declined and finished events`() { + val allDay = event(now + 1.hours, id = 1, allDay = true) + val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined) + val over = event(now - 2.hours, now - 1.hours, id = 3) + val later = event(now + 3.hours, id = 4) + val running = event(now - 1.hours, now + 2.hours, id = 5) + assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4) + // A running event ending before the next one starts comes first. + assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5) + } + + @Test + fun `a pinned series follows its next occurrence`() { + val past = event(now - 7.days) + val next = event(now + 7.days) + assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next) + assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull() + } + + @Test + fun `an all-day event counts in local days west of UTC`() { + val newYork = TimeZone.of("America/New_York") + // 21 Oct, 20:00 in New York; the vacation is all day on the 23rd. + val evening = Instant.parse("2026-10-22T00:00:00Z") + val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true) + val counted = vacation.counted(newYork) + assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z")) + assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2)) + } +}