diff options
Diffstat (limited to 'android/app/src/main/java/org/terst')
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt | 101 |
1 files changed, 92 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 |
