@@ -0,0 +1,206 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||
// split style can list a day with exactly the same visual language instead of
|
||||
// growing a parallel set of event rows.
|
||||
|
||||
@Composable
|
||||
internal fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||
* an empty selected day in the month view's split style.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event, day, zone),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||
*
|
||||
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> stringResource(
|
||||
R.string.agenda_span_starts,
|
||||
formatTime(label.start, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Ends -> stringResource(
|
||||
R.string.agenda_span_ends,
|
||||
formatTime(label.end, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||
formatTime(label.end, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(
|
||||
instant: Instant,
|
||||
zone: TimeZone,
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -17,13 +14,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -59,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
@@ -69,25 +59,15 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||
@@ -370,7 +350,10 @@ private fun AgendaList(
|
||||
if (day.events.isEmpty()) {
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
onClick = { onOpenDay(day.date) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
@@ -396,100 +379,6 @@ private fun AgendaList(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
|
||||
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
|
||||
* visible slot when nothing is left rather than a bare header.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event, day, zone),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -559,64 +448,3 @@ private fun AgendaTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||
*
|
||||
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||
*/
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> stringResource(
|
||||
R.string.agenda_span_starts,
|
||||
formatTime(label.start, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Ends -> stringResource(
|
||||
R.string.agenda_span_ends,
|
||||
formatTime(label.end, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||
formatTime(label.end, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(
|
||||
instant: Instant,
|
||||
zone: TimeZone,
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
|
||||
@@ -40,9 +40,17 @@ data class MonthWeek(
|
||||
sealed interface MonthUiState {
|
||||
data object Loading : MonthUiState
|
||||
data class Failure(val reason: FailureReason) : MonthUiState
|
||||
|
||||
/**
|
||||
* [weeks] is what the grid draws; [instancesByDay] is the same events keyed by
|
||||
* date and *uncapped*, so the split style's day pane can list everything on a
|
||||
* date without a second provider query — the month grid range already covers
|
||||
* it. All-day events sort first, then by start.
|
||||
*/
|
||||
data class Success(
|
||||
val month: YearMonth,
|
||||
val today: LocalDate,
|
||||
val weeks: List<MonthWeek>,
|
||||
val instancesByDay: Map<LocalDate, List<EventInstance>> = emptyMap(),
|
||||
) : MonthUiState
|
||||
}
|
||||
|
||||
@@ -117,10 +117,12 @@ class MonthViewModel @Inject constructor(
|
||||
if (calendars.isEmpty()) {
|
||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
today = todayDate,
|
||||
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
||||
weeks = weeks,
|
||||
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +151,22 @@ internal fun layoutMonthWeeks(
|
||||
|
||||
return (0 until weekCount).map { row ->
|
||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||
layoutCalendarWeek(days, instances, zone)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so
|
||||
* the continuous style — which streams weeks rather than months and so has no
|
||||
* enclosing [YearMonth] to slice by — lays each row out identically.
|
||||
*
|
||||
* [days] must be the row's seven consecutive dates, in display order.
|
||||
*/
|
||||
internal fun layoutCalendarWeek(
|
||||
days: List<LocalDate>,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): MonthWeek {
|
||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||
val (bars, singles) = weekEvents.partition { ev ->
|
||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||
@@ -163,7 +181,7 @@ internal fun layoutMonthWeeks(
|
||||
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||
)
|
||||
}
|
||||
MonthWeek(
|
||||
return MonthWeek(
|
||||
days = days,
|
||||
spans = spans,
|
||||
timedByDay = days.associateWith { d ->
|
||||
@@ -171,9 +189,25 @@ internal fun layoutMonthWeeks(
|
||||
},
|
||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event touching each of [days], all-day first then by start time. Unlike
|
||||
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||
* they cover and applies no display cap, so the split style's day pane can list a
|
||||
* date in full without querying the provider again.
|
||||
*/
|
||||
internal fun instancesByDay(
|
||||
days: List<LocalDate>,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): Map<LocalDate, List<EventInstance>> =
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||
* trailing days of the previous month and the leading days of the next month.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MonthLayoutTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
// 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st.
|
||||
private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE)
|
||||
private val jun1 = LocalDate(2026, 6, 1)
|
||||
private val jun8 = LocalDate(2026, 6, 8)
|
||||
private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) }
|
||||
|
||||
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
|
||||
date.atTime(h, m).toInstant(zone)
|
||||
|
||||
private fun timed(
|
||||
date: LocalDate,
|
||||
startHour: Int,
|
||||
endHour: Int,
|
||||
id: Long = 1L,
|
||||
title: String = "E",
|
||||
) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = at(date, startHour),
|
||||
end = at(date, endHour),
|
||||
isAllDay = false,
|
||||
color = 0xFF112233.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
/** All-day events live at UTC midnights with an *exclusive* end. */
|
||||
private fun allDay(
|
||||
from: LocalDate,
|
||||
toInclusive: LocalDate = from,
|
||||
id: Long = 100L,
|
||||
title: String = "A",
|
||||
) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = 0xFF445566.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `startOfGridWeek snaps back to the configured week start`() {
|
||||
val wed = LocalDate(2026, 6, 10)
|
||||
assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||
assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||
// A Sunday-anchored week containing the 10th starts on the 7th.
|
||||
assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthGridRange always covers 42 days from the grid start`() {
|
||||
val range = monthGridRange(jun, DayOfWeek.MONDAY, zone)
|
||||
assertThat(range.start).isEqualTo(at(jun1, 0))
|
||||
// 42 days inclusive → the last second of 2026-07-12.
|
||||
assertThat(range.endInclusive)
|
||||
.isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week count follows the month's shape rather than a fixed six rows`() {
|
||||
// June 2026: starts Monday, 30 days → 5 rows.
|
||||
assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5)
|
||||
// August 2026: starts Saturday, 31 days → spills to 6 rows.
|
||||
val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST)
|
||||
assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6)
|
||||
// February 2021: starts Monday, 28 days → exactly 4 rows.
|
||||
val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY)
|
||||
assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layoutMonthWeeks rows are contiguous and seven days wide`() {
|
||||
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)
|
||||
assertThat(weeks.first().days.first()).isEqualTo(jun1)
|
||||
weeks.forEach { assertThat(it.days).hasSize(7) }
|
||||
val allDays = weeks.flatMap { it.days }
|
||||
allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day event becomes one span across its columns`() {
|
||||
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||
|
||||
assertThat(week.spans).hasSize(1)
|
||||
val span = week.spans.single()
|
||||
assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th
|
||||
assertThat(span.endCol).isEqualTo(4) // Friday the 12th
|
||||
assertThat(span.lane).isEqualTo(0)
|
||||
assertThat(span.continuesLeft).isFalse()
|
||||
assertThat(span.continuesRight).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a span running past the row end is flagged as continuing`() {
|
||||
// Saturday the 13th through Tuesday the 16th straddles the row boundary.
|
||||
val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16))
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||
|
||||
val span = week.spans.single()
|
||||
assertThat(span.startCol).isEqualTo(5)
|
||||
assertThat(span.endCol).isEqualTo(6)
|
||||
assertThat(span.continuesLeft).isFalse()
|
||||
assertThat(span.continuesRight).isTrue()
|
||||
|
||||
// The following row picks it up with the flags mirrored.
|
||||
val nextRow = layoutCalendarWeek(
|
||||
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
|
||||
listOf(ev),
|
||||
zone,
|
||||
)
|
||||
val tail = nextRow.spans.single()
|
||||
assertThat(tail.startCol).isEqualTo(0)
|
||||
assertThat(tail.endCol).isEqualTo(1)
|
||||
assertThat(tail.continuesLeft).isTrue()
|
||||
assertThat(tail.continuesRight).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlapping spans are stacked on separate lanes`() {
|
||||
val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L)
|
||||
val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone)
|
||||
|
||||
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-day timed events stay pills and sort by start`() {
|
||||
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
|
||||
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone)
|
||||
|
||||
assertThat(week.spans).isEmpty()
|
||||
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId })
|
||||
.containsExactly(2L, 1L)
|
||||
.inOrder()
|
||||
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `countByDay totals bars and pills on each date`() {
|
||||
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
|
||||
val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone)
|
||||
|
||||
assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2)
|
||||
assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1)
|
||||
assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layoutMonthWeeks agrees with laying each row out on its own`() {
|
||||
val events = listOf(
|
||||
allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L),
|
||||
timed(LocalDate(2026, 6, 18), 9, 10, id = 2L),
|
||||
)
|
||||
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone)
|
||||
|
||||
weeks.forEach { row ->
|
||||
assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instancesByDay puts all-day events first then orders by start`() {
|
||||
val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L)
|
||||
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L)
|
||||
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L)
|
||||
|
||||
val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone)
|
||||
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId })
|
||||
.containsExactly(1L, 3L, 2L)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instancesByDay repeats a multi-day event on every date it covers`() {
|
||||
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||
val byDay = instancesByDay(weekOf8th, listOf(ev), zone)
|
||||
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty()
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1)
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1)
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1)
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instancesByDay covers every date in the grid, empty ones included`() {
|
||||
val byDay = instancesByDay(weekOf8th, emptyList(), zone)
|
||||
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
|
||||
assertThat(byDay.values.flatten()).isEmpty()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user