From 3ccb4eb45ff0f2a0314c36dc530d5118b59a35fd Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 13 Jul 2026 18:26:46 +0000 Subject: 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. --- .../java/org/terst/doot/widget/ui/DootWidget.kt | 101 +++++++++++++++++++-- 1 file changed, 92 insertions(+), 9 deletions(-) (limited to 'android/app/src/main') 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, 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, 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, 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, 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, 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, fragments: List, allDayEvents: List, zone: ZoneId, textSize: WidgetTextSize) { +fun TomorrowSection( + items: List, + fragments: List, + allDayEvents: List, + multiDayEvents: List>, + 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, fragments: List, allD } allDayEvents.forEach { AllDayRow(it, textSize) } + multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant) } items.forEach { item -> val isPast = false -- cgit v1.2.3