diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 18:26:46 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:43:18 +0000 |
| commit | 3ccb4eb45ff0f2a0314c36dc530d5118b59a35fd (patch) | |
| tree | 24e2f670b030433f67cf7e555d3d1a2055e944fd | |
| parent | ca4c0ea6bcb791cd40d4684376ec2ac6f1a08c53 (diff) | |
feat(widget): show multi-day calendar events on every day they span
Multi-day events (Start and End on different calendar days) are pulled
out of the normal grid/all-day pipeline and rendered as an all-day-style
row on every day they touch (Today and/or Tomorrow), labeled
starts/ends/plain per the day being rendered. Previously such an event
either only appeared in the single hourly grid slot matching its start
time (never again on later days) or, if genuinely flagged all-day,
never had its End forwarded at all.
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt | 101 | ||||
| -rw-r--r-- | android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt | 86 |
2 files changed, 178 insertions, 9 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt index 1983383..a7b09b4 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json import org.terst.doot.widget.data.* import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -44,6 +45,47 @@ fun calendarViewIntent(url: String): Intent = addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } +enum class MultiDayVariant { NONE, STARTS, ENDS, SPANS } + +/** + * The last calendar day an item's occurrence actually covers. Google + * Calendar's all-day end date is EXCLUSIVE -- a single-day all-day event on + * July 13 has end=July14 00:00 (one day past its own day; mirrors the same + * convention already handled server-side, see effectiveEndDay in + * internal/handlers/timeline.go) -- so for an all-day item this subtracts + * one day to get the real last day. Timed events' end is already the real + * end instant, so no adjustment is needed there. + */ +private fun effectiveEndDay(end: Instant, zone: ZoneId, isAllDay: Boolean): LocalDate { + val day = end.atZone(zone).toLocalDate() + return if (isAllDay) day.minusDays(1) else day +} + +/** True for a calendar event whose start and (adjusted) end fall on different calendar days. */ +internal fun isMultiDayEvent(item: WidgetItem, zone: ZoneId): Boolean { + if (item.type != "event") return false + val start = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false + val end = item.end?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false + val startDay = start.atZone(zone).toLocalDate() + val endDay = effectiveEndDay(end, zone, item.isAllDay) + return endDay.isAfter(startDay) +} + +/** + * How a multi-day item should render on renderDay. Caller must have already + * confirmed isMultiDayEvent(item) so item.start/item.end are non-null. + */ +internal fun multiDayVariant(item: WidgetItem, renderDay: LocalDate, zone: ZoneId): MultiDayVariant { + val start = Instant.parse(item.start!!).atZone(zone).toLocalDate() + val end = effectiveEndDay(Instant.parse(item.end!!), zone, item.isAllDay) + return when { + renderDay.isEqual(start) -> MultiDayVariant.STARTS + renderDay.isEqual(end) -> MultiDayVariant.ENDS + renderDay.isAfter(start) && renderDay.isBefore(end) -> MultiDayVariant.SPANS + else -> MultiDayVariant.NONE + } +} + private val json = Json { ignoreUnknownKeys = true } class DootWidget : GlanceAppWidget() { @@ -71,10 +113,25 @@ class DootWidget : GlanceAppWidget() { fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, textSize: WidgetTextSize) { val zone = ZoneId.systemDefault() val nowZoned: ZonedDateTime = now.atZone(zone) - val todayStart = nowZoned.toLocalDate().atStartOfDay(zone).toInstant() - val tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant() + val todayDate: LocalDate = nowZoned.toLocalDate() + val tomorrowDate: LocalDate = todayDate.plusDays(1) + val todayStart = todayDate.atStartOfDay(zone).toInstant() + val tomorrowStart = tomorrowDate.atStartOfDay(zone).toInstant() val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) + // Multi-day events (Start and End fall on different calendar days) are pulled out of + // the normal all-day/grid pipeline entirely and rendered as an all-day-style row on + // EVERY day they touch, labeled "starts"/"ends"/plain depending on which day is being + // rendered -- see isMultiDayEvent/multiDayVariant. + val multiDayEvents = items.filter { it.type == "event" && isMultiDayEvent(it, zone) } + val singleDayItems = items.filter { !(it.type == "event" && isMultiDayEvent(it, zone)) } + val todayMultiDay = multiDayEvents.mapNotNull { item -> + multiDayVariant(item, todayDate, zone).takeIf { it != MultiDayVariant.NONE }?.let { item to it } + } + val tomorrowMultiDay = multiDayEvents.mapNotNull { item -> + multiDayVariant(item, tomorrowDate, zone).takeIf { it != MultiDayVariant.NONE }?.let { item to it } + } + // All-day CALENDAR EVENTS (isAllDay && type == "event" -- see // TimelineItemToWidgetItem's doc comment for why undated doot/gtask // tasks, which are also flagged isAllDay, are deliberately excluded @@ -84,8 +141,8 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, tex // ordinary untimed tasks, where enough tasks ahead of them in the queue // could push their assigned slot past the visible grid range entirely // -- not merely unpinned, actually invisible. - val allDayEvents = items.filter { it.isAllDay && it.type == "event" } - val rest = items.filter { !(it.isAllDay && it.type == "event") } + val allDayEvents = singleDayItems.filter { it.isAllDay && it.type == "event" } + val rest = singleDayItems.filter { !(it.isAllDay && it.type == "event") } val todayAllDay = allDayEvents.filter { item -> val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } s == null || (s >= todayStart && s < tomorrowStart) @@ -113,7 +170,7 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, tex .filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd } val tomorrowFrags = fragments .filter { it.startTime >= tomorrowStart && it.startTime < tomorrowEnd } - val showTomorrow = tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty() + val showTomorrow = tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty() || tomorrowMultiDay.isNotEmpty() // Glance's LazyColumn (backed by a RemoteViews ListView) is what actually scrolls in an // app widget — a plain Column clips its content to the widget's current height instead. @@ -146,6 +203,10 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, tex items(count = todayAllDay.size) { index -> AllDayRow(todayAllDay[index], textSize) } + items(count = todayMultiDay.size) { index -> + val (item, variant) = todayMultiDay[index] + AllDayRow(item, textSize, variant) + } items(count = gridEnd - gridStart + 1) { index -> HourRow(gridStart + index, nowZoned, scheduledEvents, fragments, zone, textSize) @@ -153,14 +214,14 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, tex if (showTomorrow) { item { - TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, zone, textSize) + TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, tomorrowMultiDay, zone, textSize) } } } } @Composable -fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize) { +fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVariant = MultiDayVariant.NONE) { val color = sourceColor(event.source) Row( modifier = GlanceModifier @@ -171,7 +232,7 @@ fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize) { ) { Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} Text( - text = event.title, + text = event.title + multiDayLabel(event, variant), style = TextStyle( color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = textSize.scaledContentSize(13), @@ -183,6 +244,20 @@ fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize) { } } +/** "" for a plain all-day row or a "spans"-through day; " (starts/ends HH:MM)" otherwise. */ +private fun multiDayLabel(event: WidgetItem, variant: MultiDayVariant): String = when (variant) { + MultiDayVariant.STARTS -> event.start?.let { timeSuffix("starts", it) } ?: "" + MultiDayVariant.ENDS -> event.end?.let { timeSuffix("ends", it) } ?: "" + else -> "" +} + +private fun timeSuffix(verb: String, iso: String): String { + val t = Instant.parse(iso).atZone(ZoneId.systemDefault()) + if (t.hour == 0 && t.minute == 0) return "" + val minutePart = if (t.minute > 0) t.minute.toString().padStart(2, '0') else "" + return " ($verb ${hourLabel(t.hour)}$minutePart)" +} + @Composable fun RefreshButton(isRefreshing: Boolean) { Box( @@ -412,7 +487,14 @@ fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) { } @Composable -fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, allDayEvents: List<WidgetItem>, zone: ZoneId, textSize: WidgetTextSize) { +fun TomorrowSection( + items: List<WidgetItem>, + fragments: List<TaskFragment>, + allDayEvents: List<WidgetItem>, + multiDayEvents: List<Pair<WidgetItem, MultiDayVariant>>, + zone: ZoneId, + textSize: WidgetTextSize +) { Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {} Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) { @@ -427,6 +509,7 @@ fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, allD } allDayEvents.forEach { AllDayRow(it, textSize) } + multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant) } items.forEach { item -> val isPast = false diff --git a/android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt b/android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt new file mode 100644 index 0000000..2b6451f --- /dev/null +++ b/android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt @@ -0,0 +1,86 @@ +package org.terst.doot.widget.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.terst.doot.widget.data.WidgetItem +import java.time.LocalDate +import java.time.ZoneId + +class DootWidgetMultiDayTest { + private val zone: ZoneId = ZoneId.of("UTC") + + private fun event(start: String, end: String, isAllDay: Boolean = false): WidgetItem = WidgetItem( + id = "e1", title = "Event", source = "calendar", type = "event", + start = start, end = end, isAllDay = isAllDay + ) + + @Test + fun `isMultiDayEvent is false for a same-day timed event`() { + assertFalse(isMultiDayEvent(event("2026-07-13T10:00:00Z", "2026-07-13T11:00:00Z"), zone)) + } + + @Test + fun `isMultiDayEvent is true when start and end fall on different calendar days`() { + assertTrue(isMultiDayEvent(event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z"), zone)) + } + + @Test + fun `isMultiDayEvent is true for a genuine all-day multi-day event`() { + assertTrue(isMultiDayEvent(event("2026-07-13T00:00:00Z", "2026-07-15T00:00:00Z", isAllDay = true), zone)) + } + + @Test + fun `isMultiDayEvent is false for non-event types`() { + val task = WidgetItem( + id = "t1", title = "Task", source = "doot", type = "task", + start = "2026-07-13T10:00:00Z", end = "2026-07-30T22:30:00Z" + ) + assertFalse(isMultiDayEvent(task, zone)) + } + + @Test + fun `multiDayVariant returns STARTS on the event's start day`() { + val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + assertEquals(MultiDayVariant.STARTS, multiDayVariant(item, LocalDate.of(2026, 7, 13), zone)) + } + + @Test + fun `multiDayVariant returns ENDS on the event's end day`() { + val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + assertEquals(MultiDayVariant.ENDS, multiDayVariant(item, LocalDate.of(2026, 7, 30), zone)) + } + + @Test + fun `multiDayVariant returns SPANS on a day strictly between start and end`() { + val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + assertEquals(MultiDayVariant.SPANS, multiDayVariant(item, LocalDate.of(2026, 7, 20), zone)) + } + + @Test + fun `multiDayVariant returns NONE for a day outside the event's span`() { + val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + assertEquals(MultiDayVariant.NONE, multiDayVariant(item, LocalDate.of(2026, 8, 1), zone)) + } + + @Test + fun `isMultiDayEvent is false for a single-day all-day event (exclusive end-date)`() { + // Google Calendar's all-day End is EXCLUSIVE: a single-day all-day event on + // July 13 has start=Jul13 00:00, end=Jul14 00:00 (one day past its own day). + assertFalse(isMultiDayEvent(event("2026-07-13T00:00:00Z", "2026-07-14T00:00:00Z", isAllDay = true), zone)) + } + + @Test + fun `isMultiDayEvent is true for a 3-day all-day event`() { + // 3-day all-day event (Jul 13-15 inclusive) has end=Jul16 00:00 (exclusive). + assertTrue(isMultiDayEvent(event("2026-07-13T00:00:00Z", "2026-07-16T00:00:00Z", isAllDay = true), zone)) + } + + @Test + fun `multiDayVariant reports ENDS on the real last day of a multi-day all-day event, not the exclusive end date`() { + val item = event("2026-07-13T00:00:00Z", "2026-07-16T00:00:00Z", isAllDay = true) + assertEquals(MultiDayVariant.ENDS, multiDayVariant(item, LocalDate.of(2026, 7, 15), zone)) + assertEquals(MultiDayVariant.NONE, multiDayVariant(item, LocalDate.of(2026, 7, 16), zone)) + } +} |
