feat(month): show real events with continuous multi-day bars

Replace the per-day dot summary with an event-rich grid. The ViewModel now
splits the grid into week rows and, per row, resolves all-day/multi-day
events into spanning bars (reusing the week view's layoutAllDay lane math)
and single-day timed events into per-day pills.

The grid renders as an overlay: each day gets a rounded surfaceContainer
background (matching the week/day views), spanning bars draw on top so a
multi-day event is one connected bar bridging the cells it covers, and
single-day pills fill the lane slots no bar occupies on that specific day
(top-most first) so a bar-free day isn't pushed down. Up to three rows
show per day, then a "+N" dot row. Today is a filled circle on its number;
neighbour-month days are dimmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:29:38 +02:00
parent 21e7b1ff91
commit c27a645c19
4 changed files with 333 additions and 128 deletions

View File

@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- The month view now shows real events in each day instead of coloured
dots: all-day and multi-day events render as continuous bars at the top
(a multi-day event is one connected bar across the days it spans, not a
chip per day), with single-day timed events as filled pills beneath.
Up to three rows show per day, then a "+N" dot indicator for the rest.
Each day keeps a rounded surface background, matching the week and day
views; today is marked with a filled circle on its number
- The slide-out panel now has a "View" section to switch between Month, - The slide-out panel now has a "View" section to switch between Month,
Week, and Day, mirroring the top-bar switcher pill — tapping a view Week, and Day, mirroring the top-bar switcher pill — tapping a view
selects it and closes the drawer. The current view is highlighted selects it and closes the drawer. The current view is highlighted

View File

@@ -1,30 +1,30 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -43,7 +43,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -52,6 +54,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -67,12 +70,10 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
@@ -178,7 +179,6 @@ fun MonthScreen(
WeekdayHeader(weekStart = weekStart) WeekdayHeader(weekStart = weekStart)
MonthContent( MonthContent(
state = state, state = state,
weekStart = weekStart,
slideDir = slideDir, slideDir = slideDir,
onSwipeNext = goNext, onSwipeNext = goNext,
onSwipePrev = goPrev, onSwipePrev = goPrev,
@@ -193,7 +193,6 @@ fun MonthScreen(
@Composable @Composable
private fun MonthContent( private fun MonthContent(
state: MonthUiState, state: MonthUiState,
weekStart: DayOfWeek,
slideDir: Int, slideDir: Int,
onSwipeNext: () -> Unit, onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
@@ -238,7 +237,6 @@ private fun MonthContent(
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid( is MonthUiState.Success -> MonthGrid(
state = s, state = s,
weekStart = weekStart,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
) )
} }
@@ -308,140 +306,279 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
} }
} }
private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp
private val CELL_SHAPE = RoundedCornerShape(12.dp)
private const val MAX_EVENT_ROWS = 3
@Composable @Composable
private fun MonthGrid( private fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
weekStart: DayOfWeek,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
) { ) {
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
// Show only the weeks the current month actually touches; leading/trailing
// days of neighbouring months are left blank rather than rendered.
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
val weeks = (leadOffset + daysInMonth + 6) / 7
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 4.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
repeat(weeks) { row -> state.weeks.forEach { week ->
Row( MonthWeekRow(
week = week,
today = state.today,
month = state.month,
onOpenDay = onOpenDay,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp), )
}
}
}
/**
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
* a multi-day event is one connected bar across the columns; single-day timed
* events sit beneath them as filled pills in their own cell. The cap is
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day.
* A transparent per-day layer on top turns a tap into "open that day".
*/
@Composable
private fun MonthWeekRow(
week: MonthWeek,
today: LocalDate,
month: YearMonth,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
BoxWithConstraints(modifier) {
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language.
// Spanning bars draw on top of these, bridging cells, so they still read
// as one continuous event.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
val inMonth = d.month == month.month && d.year == month.year
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE,
),
)
}
}
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) {
week.days.forEach { d ->
DayNumberCell(
date = d,
isToday = d == today,
inMonth = d.month == month.month && d.year == month.year,
modifier = Modifier.weight(1f),
)
}
}
// Breathing room between the day number (and today's circle) and the
// first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clipToBounds(),
) { ) {
repeat(7) { col -> // Spanning bars on their shared lanes.
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY) week.spans.filter { it.lane < shownLanes }.forEach { span ->
val inMonth = val cols = span.endCol - span.startCol + 1
date.month == state.month.month && date.year == state.month.year MonthBar(
if (inMonth) { event = span.event,
DayCard( dark = dark,
date = date, continuesLeft = span.continuesLeft,
isToday = date == state.today, continuesRight = span.continuesRight,
data = state.cells[date], modifier = Modifier
onClick = { onOpenDay(date) }, .offset(
modifier = Modifier.weight(1f), x = colW * span.startCol,
y = EVENT_ROW_HEIGHT * span.lane,
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
week.days.forEachIndexed { col, d ->
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
MonthBar(
event = ev,
dark = dark,
continuesLeft = false,
continuesRight = false,
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenColors = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) }
timed.drop(pillsShown.size).forEach { add(it.color) }
}.distinct().take(3)
OverflowDots(
colors = hiddenColors,
extra = hidden - hiddenColors.size,
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
.width(colW)
.padding(horizontal = 3.dp),
) )
} else {
Spacer(Modifier.weight(1f))
} }
} }
} }
} }
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class) // Tap layer: in month view a tap on any day opens that day. Padded and
@Composable // clipped to the background pill so the ripple matches it.
private fun DayCard( Row(Modifier.matchParentSize()) {
date: LocalDate, week.days.forEach { d ->
isToday: Boolean, Box(
data: DayCellData?, Modifier
onClick: () -> Unit, .weight(1f)
modifier: Modifier = Modifier, .fillMaxHeight()
) { .padding(horizontal = CELL_GAP, vertical = 1.dp)
val todayPrefix = stringResource(R.string.month_a11y_today_prefix) .clip(CELL_SHAPE)
val cellLabel = buildString { .clickable { onOpenDay(d) },
if (isToday) append(todayPrefix).append(", ") )
append(date.year).append('-')
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
append(date.day.toString().padStart(2, '0'))
data?.let { append(", ").append(it.count).append(" Events") }
}
// M3 Expressive press feedback: a spatial spring from the active motion
// scheme drives a subtle scale, instead of a fixed easing curve.
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.94f else 1f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "day-card-press",
)
Card(
onClick = onClick,
interactionSource = interactionSource,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface,
),
modifier = modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
} }
.semantics { contentDescription = cellLabel },
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 4.dp, bottom = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
)
Spacer(Modifier.height(2.dp))
EventDotRow(data)
} }
} }
} }
@Composable @Composable
private fun EventDotRow(data: DayCellData?) { private fun DayNumberCell(
if (data == null || data.swatches.isEmpty()) { date: LocalDate,
Spacer(Modifier.height(6.dp)) isToday: Boolean,
return inMonth: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.height(DAY_NUMBER_HEIGHT),
contentAlignment = Alignment.Center,
) {
if (isToday) {
Box(
modifier = Modifier
.size(DAY_NUMBER_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
} else {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelMedium,
color = if (inMonth) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
}
} }
val dark = isSystemInDarkTheme() }
/** A filled event pill/bar — pastelized fill, title clipped to one line. */
@Composable
private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance,
dark: Boolean,
continuesLeft: Boolean,
continuesRight: Boolean,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
topEnd = if (continuesRight) 0.dp else 4.dp,
bottomEnd = if (continuesRight) 0.dp else 4.dp,
)
Box(
modifier = modifier
.background(pastelize(event.color, dark), shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
)
}
}
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
@Composable
private fun OverflowDots(
colors: List<Int>,
extra: Int,
dark: Boolean,
modifier: Modifier = Modifier,
) {
Row( Row(
modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
data.swatches.forEach { argb -> colors.forEach { argb ->
Box( Box(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(6.dp)
.background(pastelize(argb, dark), CircleShape), .background(pastelize(argb, dark), CircleShape),
) )
} }
if (data.count > data.swatches.size) { if (extra > 0) {
Text( Text(
text = "+${data.count - data.swatches.size}", text = "+$extra",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )

View File

@@ -1,20 +1,40 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
/** /**
* Per-day aggregation surfaced to the month grid. We only need * An all-day or multi-day event laid out as one connected horizontal bar across
* - the total event count (drives the optional "+N" indicator), and * a week row, [startCol]..[endCol], stacked on [lane] so overlapping spans don't
* - up to three calendar colors for the dot row. * collide. Mirrors the week view's [de.jeanlucmakiola.calendula.ui.week.AllDaySpan]
* * but adds clip flags so a bar that started in an earlier week (or runs into a
* The day cell never holds full event objects — the detail sheet pulls those * later one) drops its rounded cap on that side.
* lazily.
*/ */
data class DayCellData( data class MonthSpan(
val count: Int, val event: EventInstance,
val swatches: List<Int>, val startCol: Int,
val endCol: Int,
val lane: Int,
val continuesLeft: Boolean,
val continuesRight: Boolean,
)
/**
* One week row of the grid with its events resolved for rendering.
*
* - [spans] are the all-day/multi-day bars, lanes already assigned for the row.
* - [timedByDay] holds the single-day timed events per date, sorted by start;
* these render as filled pills beneath the bar lanes in their own cell.
* - [countByDay] is the total number of events touching each date (bars + pills),
* so the cell can compute the "+N" overflow once the visible-row cap is known.
*/
data class MonthWeek(
val days: List<LocalDate>,
val spans: List<MonthSpan>,
val timedByDay: Map<LocalDate, List<EventInstance>>,
val countByDay: Map<LocalDate, Int>,
) )
sealed interface MonthUiState { sealed interface MonthUiState {
@@ -23,6 +43,6 @@ sealed interface MonthUiState {
data class Success( data class Success(
val month: YearMonth, val month: YearMonth,
val today: LocalDate, val today: LocalDate,
val cells: Map<LocalDate, DayCellData>, val weeks: List<MonthWeek>,
) : MonthUiState ) : MonthUiState
} }

View File

@@ -10,6 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -71,7 +73,7 @@ class MonthViewModel @Inject constructor(
repository.calendars(), repository.calendars(),
repository.instances(range), repository.instances(range),
) { calendars, instances -> ) { calendars, instances ->
buildState(ym, calendars, instances) buildState(ym, ws, calendars, instances)
} }
} }
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -96,25 +98,64 @@ class MonthViewModel @Inject constructor(
private fun buildState( private fun buildState(
ym: YearMonth, ym: YearMonth,
weekStart: DayOfWeek,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): MonthUiState { ): MonthUiState {
if (calendars.isEmpty()) { if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured) return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
.mapValues { (_, evs) ->
DayCellData(
count = evs.size,
swatches = evs.map { it.color }.distinct().take(3),
)
}
return MonthUiState.Success( return MonthUiState.Success(
month = ym, month = ym,
today = todayDate, today = todayDate,
cells = byDay, weeks = layoutMonth(ym, weekStart, instances),
) )
} }
/**
* Split the grid into week rows and resolve each row's events. An event is a
* spanning bar when it's all-day or touches more than one of the row's days;
* everything else is a single-day timed pill. Bars get lanes from the shared
* [layoutAllDay] so a multi-day event stays on one row across the week.
*/
private fun layoutMonth(
ym: YearMonth,
weekStart: DayOfWeek,
instances: List<EventInstance>,
): List<MonthWeek> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
val weekCount = (leadOffset + daysInMonth + 6) / 7
return (0 until weekCount).map { row ->
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
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
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
event = s.event,
startCol = s.startCol,
endCol = s.endCol,
lane = s.lane,
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
)
}
MonthWeek(
days = days,
spans = spans,
timedByDay = days.associateWith { d ->
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
)
}
}
} }
/** /**