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:
@@ -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
|
||||||
|
|||||||
@@ -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),
|
)
|
||||||
) {
|
}
|
||||||
repeat(7) { col ->
|
}
|
||||||
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
|
}
|
||||||
val inMonth =
|
|
||||||
date.month == state.month.month && date.year == state.month.year
|
/**
|
||||||
if (inMonth) {
|
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
||||||
DayCard(
|
* a multi-day event is one connected bar across the columns; single-day timed
|
||||||
date = date,
|
* events sit beneath them as filled pills in their own cell. The cap is
|
||||||
isToday = date == state.today,
|
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day.
|
||||||
data = state.cells[date],
|
* A transparent per-day layer on top turns a tap into "open that day".
|
||||||
onClick = { onOpenDay(date) },
|
*/
|
||||||
|
@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),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Spacer(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(),
|
||||||
|
) {
|
||||||
|
// Spanning bars on their shared lanes.
|
||||||
|
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||||
|
val cols = span.endCol - span.startCol + 1
|
||||||
|
MonthBar(
|
||||||
|
event = span.event,
|
||||||
|
dark = dark,
|
||||||
|
continuesLeft = span.continuesLeft,
|
||||||
|
continuesRight = span.continuesRight,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||||
|
// clipped to the background pill so the ripple matches it.
|
||||||
|
Row(Modifier.matchParentSize()) {
|
||||||
|
week.days.forEach { d ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.clip(CELL_SHAPE)
|
||||||
|
.clickable { onOpenDay(d) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayCard(
|
private fun DayNumberCell(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
data: DayCellData?,
|
inMonth: Boolean,
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
Box(
|
||||||
val cellLabel = buildString {
|
modifier = modifier.height(DAY_NUMBER_HEIGHT),
|
||||||
if (isToday) append(todayPrefix).append(", ")
|
contentAlignment = Alignment.Center,
|
||||||
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(
|
if (isToday) {
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
.padding(top = 4.dp, bottom = 2.dp),
|
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = date.day.toString(),
|
text = date.day.toString(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
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),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
EventDotRow(data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A filled event pill/bar — pastelized fill, title clipped to one line. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDotRow(data: DayCellData?) {
|
private fun MonthBar(
|
||||||
if (data == null || data.swatches.isEmpty()) {
|
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||||
Spacer(Modifier.height(6.dp))
|
dark: Boolean,
|
||||||
return
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val dark = isSystemInDarkTheme()
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user