feat(agenda): day-aware time line for multi-day events
A multi-day event repeated the same "start – end" on every day it spans
("14:00 – 14:00"), which reads as meaningless. Show only the part
relevant to each day, with a "→" marking that it carries past the day's
boundary: the first day shows the start ("14:00 →"), the last day the
end ("→ 10:00"), and whole days in between an all-day span arriving from
and continuing into their neighbours ("→ All day →"). Single-day rows are
unchanged.
Factors the span first/last-day resolution into shared EventInstance
helpers reused by groupAgendaDays and the label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -374,6 +374,7 @@ private fun AgendaList(
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day.date,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
@@ -452,6 +453,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -463,7 +465,7 @@ private fun AgendaEventRow(
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
summary = agendaTimeSummary(event, day),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
@@ -557,16 +559,41 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||
/**
|
||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||
*
|
||||
* A multi-day event shows only the part relevant to [day], with a "→" marking
|
||||
* that it carries past this day's boundary: its first day shows the start
|
||||
* ("14:00 →"), its last day the end ("→ 10:00"), and any whole day in between
|
||||
* reads as an all-day span arriving from and continuing into its neighbours
|
||||
* ("→ All day →").
|
||||
*/
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String {
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
val time = if (event.spansMultipleDays(zone)) {
|
||||
val continuesBefore = day > event.spanFirstDay(zone)
|
||||
val continuesAfter = day < event.spanLastDay(zone)
|
||||
val core = when {
|
||||
event.isAllDay -> allDayLabel
|
||||
!continuesBefore -> formatTime(event.start, is24Hour, locale) // the first day
|
||||
!continuesAfter -> formatTime(event.end, is24Hour, locale) // the last day
|
||||
else -> allDayLabel // a full middle day
|
||||
}
|
||||
buildString {
|
||||
if (continuesBefore) append("→ ")
|
||||
append(core)
|
||||
if (continuesAfter) append(" →")
|
||||
}
|
||||
} else if (event.isAllDay) {
|
||||
allDayLabel
|
||||
} else {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
}
|
||||
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
@@ -9,6 +9,24 @@ import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/** The first calendar day this event occupies, in [zone]. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(zone).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies, in [zone]. An event ending
|
||||
* exactly at midnight (all-day events end at the exclusive next-midnight) does
|
||||
* not reach into that boundary day, so resolve the instant just before [end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(zone).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
/** One calendar day with at least one event, for the agenda list. */
|
||||
data class AgendaDay(
|
||||
val date: LocalDate,
|
||||
@@ -35,12 +53,8 @@ fun groupAgendaDays(
|
||||
): List<AgendaDay> {
|
||||
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||
for (instance in instances) {
|
||||
val firstDay = instance.start.toLocalDateTime(zone).date.coerceAtLeast(anchor)
|
||||
// The last day the event actually occupies. An event ending exactly at
|
||||
// midnight (all-day events end at the exclusive next-midnight) does not
|
||||
// reach into that boundary day, so resolve the instant just before [end].
|
||||
val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start
|
||||
val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd)
|
||||
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||
var day = firstDay
|
||||
while (day <= lastDay) {
|
||||
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||
|
||||
Reference in New Issue
Block a user