summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt101
-rw-r--r--android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt86
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))
+ }
+}