This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.seating
|
||||
import de.jeanlucmakiola.calendula.ui.month.spanAt
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */
|
||||
internal const val MAX_LANES = 3
|
||||
|
||||
/**
|
||||
* Glance gives each rendering 500 view ids and fails the whole widget past them
|
||||
* (#372). The grid stays under this, leaving room for Glance's own wrappers.
|
||||
*/
|
||||
internal const val VIEW_BUDGET = 440
|
||||
|
||||
/** Views outside the week rows: the header row with its title and three icons, a spacer and the weekday row. */
|
||||
internal const val FIXED_VIEWS = 18
|
||||
|
||||
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
||||
internal data class LaneCell(val event: EventInstance?, val cols: Int)
|
||||
|
||||
/** A "+[count]" label one column wide, or — with a zero count — a gap [cols] wide. */
|
||||
internal data class OverflowCell(val count: Int, val cols: Int)
|
||||
|
||||
/**
|
||||
* Lane [lane] split into bars and the gaps between them, consecutive blank
|
||||
* columns merged into one gap: the day-open taps come from the strip underneath,
|
||||
* so a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
||||
*/
|
||||
internal fun MonthWeek.laneCells(lane: Int, laneCap: Int): List<LaneCell> {
|
||||
val cells = mutableListOf<LaneCell>()
|
||||
var gap = 0
|
||||
var col = 0
|
||||
while (col < days.size) {
|
||||
val seat = seating(col, laneCap).seated.firstOrNull { it.lane == lane }
|
||||
if (seat == null) {
|
||||
gap += 1
|
||||
col += 1
|
||||
continue
|
||||
}
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
gap = 0
|
||||
val cols = if (seat.bar) (spanAt(col, lane)?.endCol ?: col) - col + 1 else 1
|
||||
cells += LaneCell(seat.event, cols)
|
||||
col += cols
|
||||
}
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
return cells
|
||||
}
|
||||
|
||||
/** How many lanes this week fills from the top under [laneCap]; empty lanes below are not drawn. */
|
||||
internal fun MonthWeek.usedLanes(laneCap: Int): Int =
|
||||
days.indices.maxOfOrNull { col -> seating(col, laneCap).seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
|
||||
|
||||
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
||||
internal fun MonthWeek.hiddenPerDay(laneCap: Int): List<Int> =
|
||||
days.indices.map { col -> seating(col, laneCap).hidden.size }
|
||||
|
||||
/** The "+N" row as labels and merged gaps; empty when nothing is hidden. */
|
||||
internal fun overflowCells(hidden: List<Int>): List<OverflowCell> {
|
||||
if (hidden.none { it > 0 }) return emptyList()
|
||||
val cells = mutableListOf<OverflowCell>()
|
||||
var gap = 0
|
||||
hidden.forEach { count ->
|
||||
if (count == 0) {
|
||||
gap += 1
|
||||
} else {
|
||||
if (gap > 0) cells += OverflowCell(0, gap)
|
||||
gap = 0
|
||||
cells += OverflowCell(count, 1)
|
||||
}
|
||||
}
|
||||
if (gap > 0) cells += OverflowCell(0, gap)
|
||||
return cells
|
||||
}
|
||||
|
||||
/** The views `WeekRow` emits for this week with [laneCap] lanes, counted the way it draws them. */
|
||||
internal fun MonthWeek.viewCount(laneCap: Int, today: LocalDate): Int {
|
||||
val strips = 1 + days.size
|
||||
val numbers = 1 + days.size + if (today in days) 2 else 0
|
||||
val lanes = (0 until usedLanes(laneCap)).sumOf { lane ->
|
||||
2 + laneCells(lane, laneCap).sumOf { if (it.event == null) 1 else 3 }
|
||||
}
|
||||
val overflow = overflowCells(hiddenPerDay(laneCap)).let { if (it.isEmpty()) 0 else 1 + it.size }
|
||||
// The week's Box, its content Column and the spacer under the numbers.
|
||||
return 3 + strips + numbers + lanes + overflow
|
||||
}
|
||||
|
||||
/**
|
||||
* The lanes each week may draw so the grid fits [budget] views: the most
|
||||
* expensive week gives up its lowest lane first, its events folding into "+N",
|
||||
* until the whole month fits (#372).
|
||||
*/
|
||||
internal fun laneCaps(
|
||||
weeks: List<MonthWeek>,
|
||||
today: LocalDate,
|
||||
budget: Int = VIEW_BUDGET - FIXED_VIEWS,
|
||||
): List<Int> {
|
||||
val caps = MutableList(weeks.size) { MAX_LANES }
|
||||
val costs = MutableList(weeks.size) { weeks[it].viewCount(MAX_LANES, today) }
|
||||
while (costs.sum() > budget) {
|
||||
val busiest = weeks.indices.filter { caps[it] > 0 }.maxByOrNull { costs[it] } ?: break
|
||||
caps[busiest] -= 1
|
||||
costs[busiest] = weeks[busiest].viewCount(caps[busiest], today)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
@@ -86,8 +86,6 @@ import java.util.Locale
|
||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||
|
||||
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||
private const val MAX_LANES = 3
|
||||
private val LANE_HEIGHT = 14.dp
|
||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||
private val GRID_HPADDING = 8.dp
|
||||
@@ -225,13 +223,15 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
// Column width from the live widget size, minus our H padding.
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
val caps = laneCaps(weeks, source.today)
|
||||
|
||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
weeks.forEach { week ->
|
||||
weeks.forEachIndexed { i, week ->
|
||||
WeekRow(
|
||||
week = week,
|
||||
laneCap = caps[i],
|
||||
currentMonth = ym.month,
|
||||
today = source.today,
|
||||
soften = source.soften,
|
||||
@@ -341,6 +341,7 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
@Composable
|
||||
private fun WeekRow(
|
||||
week: MonthWeek,
|
||||
laneCap: Int,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
soften: Boolean,
|
||||
@@ -348,13 +349,13 @@ private fun WeekRow(
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val hidden = week.hiddenPerDay()
|
||||
val overflow = overflowCells(week.hiddenPerDay(laneCap))
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
// Day-open targets, one full-height strip per column, sitting *under* the
|
||||
// content: a tap anywhere in a day still opens it, as in the app, without
|
||||
// every cell in the column carrying a click target of its own. The content
|
||||
// above is not clickable, so touches fall through to this layer; only an
|
||||
// event bar opts out to open its own detail.
|
||||
// above is not clickable, event bars included, so touches fall through to
|
||||
// this layer and a day opens wherever it is tapped (#386).
|
||||
Row(modifier = GlanceModifier.fillMaxSize()) {
|
||||
week.days.forEach { date ->
|
||||
Box(
|
||||
@@ -385,42 +386,21 @@ private fun WeekRow(
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
// Lanes past the last one this week fills emit nothing at all.
|
||||
repeat(week.usedLanes()) { lane ->
|
||||
LaneRow(week = week, lane = lane, soften = soften, colW = colW)
|
||||
// The grid's view budget caps the lanes per week (#372); what doesn't
|
||||
// fit folds into the "+N" row. Mirrored one to one by viewCount.
|
||||
repeat(week.usedLanes(laneCap)) { lane ->
|
||||
LaneRow(cells = week.laneCells(lane, laneCap), soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
||||
if (overflow.isNotEmpty()) OverflowRow(cells = overflow, colW = colW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lanes this week actually fills, counting from the top. The grid draws
|
||||
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
||||
* per day and most weeks leave the last two empty.
|
||||
*/
|
||||
private fun MonthWeek.usedLanes(): Int {
|
||||
for (lane in MAX_LANES - 1 downTo 0) {
|
||||
val filled = spans.any { it.lane == lane } ||
|
||||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
||||
if (filled) return lane + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
||||
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
||||
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
||||
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
||||
* on a day opens it; only an event bar on top opts out to open its own detail.
|
||||
* The only tap target in a day column, so a tap anywhere on a day opens it.
|
||||
*/
|
||||
private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
@@ -459,50 +439,10 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
}
|
||||
}
|
||||
|
||||
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
||||
private data class LaneCell(val event: EventInstance?, val cols: Int)
|
||||
|
||||
/**
|
||||
* A lane split into bars and the gaps between them, with consecutive blank columns
|
||||
* merged into one gap: the week's day-open taps come from the strip underneath, so
|
||||
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
||||
*/
|
||||
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
|
||||
val cells = mutableListOf<LaneCell>()
|
||||
var gap = 0
|
||||
var col = 0
|
||||
fun closeGap() {
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
gap = 0
|
||||
}
|
||||
while (col < 7) {
|
||||
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
|
||||
when {
|
||||
span != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(span.event, span.endCol - col + 1)
|
||||
col = span.endCol + 1
|
||||
}
|
||||
timed != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(timed, 1)
|
||||
col += 1
|
||||
}
|
||||
else -> {
|
||||
gap += 1
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
closeGap()
|
||||
return cells
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, soften: Boolean, colW: Dp) {
|
||||
private fun LaneRow(cells: List<LaneCell>, soften: Boolean, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.laneCells(lane).forEach { cell ->
|
||||
cells.forEach { cell ->
|
||||
if (cell.event == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
@@ -526,19 +466,7 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) {
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(LANE_HEIGHT)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Tap an event bar to open its detail, rooted in the month view.
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Month,
|
||||
),
|
||||
),
|
||||
),
|
||||
.padding(horizontal = 1.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
@@ -563,40 +491,23 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) {
|
||||
|
||||
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
||||
@Composable
|
||||
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
|
||||
private fun OverflowRow(cells: List<OverflowCell>, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var gap = 0
|
||||
hidden.forEach { count ->
|
||||
if (count == 0) {
|
||||
gap += 1
|
||||
return@forEach
|
||||
cells.forEach { cell ->
|
||||
if (cell.count == 0) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
Text(
|
||||
text = "+${cell.count}",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
if (gap > 0) {
|
||||
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
gap = 0
|
||||
}
|
||||
Text(
|
||||
text = "+$count",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
/** The timed single-day event that fills lane [lane] on day [col], if any. */
|
||||
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
|
||||
val occupied = week.spans
|
||||
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
|
||||
val timed = week.timedByDay[date].orEmpty()
|
||||
return timed.getOrNull(freeSlots.indexOf(lane))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionMessage() {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.timedEvent
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
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.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** The month widget's view budget (#372). */
|
||||
class MonthGridTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
private val today = LocalDate(2026, 7, 15)
|
||||
|
||||
/** July 2026 with [perDay] timed events on every day of the grid. */
|
||||
private fun weeks(perDay: Int): List<de.jeanlucmakiola.calendula.ui.month.MonthWeek> {
|
||||
val first = LocalDate(2026, 6, 29)
|
||||
var id = 0L
|
||||
val events = (0 until 42).flatMap { offset ->
|
||||
val day = first.plus(offset, DateTimeUnit.DAY)
|
||||
(1..perDay).map { hour ->
|
||||
timedEvent(day, hour, zone = zone, id = ++id, title = "E$id", color = 0xFFF44336.toInt())
|
||||
}
|
||||
}
|
||||
return layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a quiet month keeps every lane`() {
|
||||
val weeks = weeks(perDay = 1)
|
||||
assertThat(laneCaps(weeks, today)).containsExactlyElementsIn(List(weeks.size) { MAX_LANES })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full month folds lanes until it fits the budget`() {
|
||||
val weeks = weeks(perDay = 5)
|
||||
val fullCost = weeks.sumOf { it.viewCount(MAX_LANES, today) }
|
||||
assertThat(fullCost).isGreaterThan(VIEW_BUDGET - FIXED_VIEWS)
|
||||
|
||||
val caps = laneCaps(weeks, today)
|
||||
val cost = weeks.indices.sumOf { weeks[it].viewCount(caps[it], today) }
|
||||
assertThat(cost).isAtMost(VIEW_BUDGET - FIXED_VIEWS)
|
||||
assertThat(caps.any { it < MAX_LANES }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `folded events are counted in the overflow`() {
|
||||
val week = weeks(perDay = 5)[1]
|
||||
val hidden = week.hiddenPerDay(laneCap = 1)
|
||||
assertThat(hidden).containsExactlyElementsIn(List(7) { 4 })
|
||||
assertThat(week.usedLanes(laneCap = 1)).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lane of single-day events is one cell per day`() {
|
||||
val cells = weeks(perDay = 1)[1].laneCells(lane = 0, laneCap = MAX_LANES)
|
||||
assertThat(cells).hasSize(7)
|
||||
assertThat(cells.all { it.event != null && it.cols == 1 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overflow gaps are merged`() {
|
||||
assertThat(overflowCells(listOf(0, 0, 2, 0, 0, 0, 1))).containsExactly(
|
||||
OverflowCell(0, 2),
|
||||
OverflowCell(2, 1),
|
||||
OverflowCell(0, 3),
|
||||
OverflowCell(1, 1),
|
||||
).inOrder()
|
||||
assertThat(overflowCells(List(7) { 0 })).isEmpty()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user