@@ -902,7 +902,9 @@ private fun SplitMonthContent(
|
||||
*/
|
||||
private val SPLIT_ROW_HEIGHT = 46.dp
|
||||
private val SPLIT_DOT_SIZE = 5.dp
|
||||
private const val SPLIT_MAX_DOTS = 3
|
||||
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for
|
||||
// the paged grid's lanes, so the two caps have to be the same number or a dot
|
||||
// would have no bar to become (#53).
|
||||
|
||||
/**
|
||||
* Rows the split grid always reserves — the most any month needs. A month that
|
||||
@@ -945,11 +947,15 @@ internal fun SplitMonthGrid(
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
week.days.forEach { day ->
|
||||
week.days.forEachIndexed { col, day ->
|
||||
val inMonth = day.month == month.month && day.year == month.year
|
||||
// Seated by lane rather than gathered by colour, so each dot
|
||||
// is the event the expanded grid draws in that same lane.
|
||||
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS)
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = state.instancesByDay[day].orEmpty(),
|
||||
events = seated,
|
||||
hidden = (week.countByDay[day] ?: 0) - seated.size,
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -979,7 +985,7 @@ internal fun SplitMonthGrid(
|
||||
}
|
||||
|
||||
/**
|
||||
* One compact day: its number over up to [SPLIT_MAX_DOTS] event dots.
|
||||
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots.
|
||||
*
|
||||
* Selection and today are deliberately different signals — a tinted, outlined
|
||||
* cell versus the filled circle the other views already use for today — so the
|
||||
@@ -989,6 +995,8 @@ internal fun SplitMonthGrid(
|
||||
private fun SplitDayCell(
|
||||
date: LocalDate,
|
||||
events: List<EventInstance>,
|
||||
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||
hidden: Int,
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
inMonth: Boolean,
|
||||
@@ -1037,32 +1045,37 @@ private fun SplitDayCell(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
SplitDots(events = events, dark = dark)
|
||||
SplitDots(events = events, hidden = hidden, dark = dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Up to three colour dots for a day, plus a count when more are hidden. */
|
||||
/**
|
||||
* One dot per seated event, in lane order, plus a "+N" for those that didn't fit.
|
||||
*
|
||||
* Deliberately *not* de-duplicated by colour: [events] arrives lane-seated so the
|
||||
* dots line up with the bars the expanded grid draws, and collapsing two events
|
||||
* that share a calendar into one dot would both undercount the day and leave a
|
||||
* bar with no dot to grow out of.
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
|
||||
private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
||||
if (events.isEmpty()) return
|
||||
val soften = LocalSoftenColors.current
|
||||
val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) }
|
||||
val extra = events.size - colors.size
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
colors.forEach { argb ->
|
||||
events.forEach { event ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SPLIT_DOT_SIZE)
|
||||
.background(eventFill(argb, dark, soften), CircleShape),
|
||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
if (extra > 0) {
|
||||
if (hidden > 0) {
|
||||
Text(
|
||||
text = "+$extra",
|
||||
text = "+$hidden",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -39,6 +39,34 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||
* order, capped at [laneCap] lanes.
|
||||
*
|
||||
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||
*
|
||||
* Deriving the dots independently — by distinct colour, as they first were —
|
||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||
* that shared a calendar into a single dot.
|
||||
*/
|
||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||
byLane[span.lane] = span.event
|
||||
}
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||
byLane[free[i]] = event
|
||||
}
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What lets the split style's dots morph into the paged style's bars (#53): both
|
||||
* read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event.
|
||||
*/
|
||||
class LaneEventsTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
|
||||
/** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */
|
||||
private fun rowOfJuly6(events: List<EventInstance>) =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
|
||||
|
||||
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
|
||||
EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "A$id",
|
||||
start = from.atTime(0, 0).toInstant(zone),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||
isAllDay = true,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T$id",
|
||||
start = date.atTime(hour, 0).toInstant(zone),
|
||||
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a bar keeps its lane and the day's timed events fill what's left`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, meeting))
|
||||
|
||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
||||
.containsExactly(bar, meeting)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day the bar misses seats its own events from lane zero`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, monday))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
.containsExactly(monday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day bar is seated on every day it covers`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val week = rowOfJuly6(listOf(bar))
|
||||
|
||||
(1..3).forEach { col ->
|
||||
val day = LocalDate(2026, 7, 6 + col)
|
||||
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
||||
}
|
||||
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||
}
|
||||
|
||||
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||
@Test
|
||||
fun `events sharing a colour each keep their own lane`() {
|
||||
val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED)
|
||||
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||
val week = rowOfJuly6(listOf(first, second))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
.containsExactly(first, second)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seating stops at the cap and leaves the rest to the overflow count`() {
|
||||
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||
val week = rowOfJuly6(events)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||
}
|
||||
|
||||
/** A bar parked below the cap is out of view, so it takes no dot with it. */
|
||||
@Test
|
||||
fun `a bar beyond the cap is left out`() {
|
||||
val bars = (0 until 4).map {
|
||||
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||
}
|
||||
val week = rowOfJuly6(bars)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||
.containsNoneIn(seated)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BLUE = 0xFF3366CC.toInt()
|
||||
const val RED = 0xFFCC3333.toInt()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user