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