Compare commits

...

3 Commits

Author SHA1 Message Date
15fb76005c release: cut v2.1.0 — month event grid, drawer view tabs, text-cursor fix
All checks were successful
CI / ci (push) Successful in 8m30s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m3s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m57s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:30:20 +02:00
c27a645c19 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>
2026-06-15 22:29:38 +02:00
21e7b1ff91 feat(drawer): add View section to switch Month/Week/Day
The slide-out panel gains a "View" section mirroring the top-bar switcher
pill: three NavigationDrawerItems (Month/Week/Day) with the current view
highlighted; tapping one selects that view and closes the drawer. The pill
stays as-is for quick cycling.

Centralise each view's label + icon as labelRes/icon extensions on
CalendarView so the pill and the drawer share one mapping. The drawer's
"Today" jump is dropped — the top-bar Today action and error-state retry
still cover it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:48:49 +02:00
12 changed files with 402 additions and 155 deletions

View File

@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.1.0] — 2026-06-15
### 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,
Week, and Day, mirroring the top-bar switcher pill — tapping a view
selects it and closes the drawer. The current view is highlighted
### Fixed ### Fixed
- Typing in the event title, location, and description fields no longer - Typing in the event title, location, and description fields no longer
makes the cursor jump around: the form state's round-trip to the UI was makes the cursor jump around: the form state's round-trip to the UI was

View File

@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md. // default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20000 versionCode = 20100
versionName = "2.0.0" versionName = "2.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -27,16 +26,19 @@ import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
* Visual language (kept deliberately small so sizes don't drift): * Visual language (kept deliberately small so sizes don't drift):
* - Drawer title — `titleLarge` * - Drawer title — `titleLarge`
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only * - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
* - Nav items (Today / Settings) — Material `NavigationDrawerItem` * - Nav items (the views, Today / Settings) — Material `NavigationDrawerItem`
* (`labelLarge` label + a single 24dp leading icon) * (`labelLarge` label + a single 24dp leading icon)
* *
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with * The "View" section mirrors the top-bar switcher pill: tapping a view here
* its checkboxes lives here rather than in a separate sheet — plus the "today" * selects it (and closes the drawer) rather than cycling. Also hosts the
* jump and a Settings entry (M4). The host screen owns the drawer state. * per-calendar visibility filter (M3) inline — the calendar list with its
* checkboxes lives here rather than in a separate sheet — plus a Settings
* entry (M4). The host screen owns the drawer state.
*/ */
@Composable @Composable
fun CalendarDrawer( fun CalendarDrawer(
onToday: () -> Unit, currentView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
) { ) {
ModalDrawerSheet { ModalDrawerSheet {
@@ -47,14 +49,17 @@ fun CalendarDrawer(
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
) )
HorizontalDivider() HorizontalDivider()
Spacer(Modifier.height(8.dp))
DrawerSectionHeader(stringResource(R.string.view_section))
IMPLEMENTED_VIEWS.forEach { view ->
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Today, contentDescription = null) }, icon = { Icon(view.icon, contentDescription = null) },
label = { Text(stringResource(R.string.month_today_action)) }, label = { Text(stringResource(view.labelRes)) },
selected = false, selected = view == currentView,
onClick = onToday, onClick = { onSelectView(view) },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
HorizontalDivider() HorizontalDivider()

View File

@@ -1,5 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R
/** /**
* The top-level calendar views the user can switch between (spec M1). * The top-level calendar views the user can switch between (spec M1).
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS]. * Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
@@ -10,6 +18,23 @@ enum class CalendarView {
Day, Day,
} }
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
@get:StringRes
val CalendarView.labelRes: Int
get() = when (this) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
}
/** Leading icon for the view in the drawer's View section. */
val CalendarView.icon: ImageVector
get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.Day -> Icons.Filled.CalendarViewDay
}
/** /**
* Views that actually have a screen today. The view-switcher pill cycles * Views that actually have a screen today. The view-switcher pill cycles
* through these in order. * through these in order.

View File

@@ -6,7 +6,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/** /**
* Top-bar pill that shows the current view and cycles to the next one on tap * Top-bar pill that shows the current view and cycles to the next one on tap
@@ -18,16 +17,11 @@ fun ViewSwitcherPill(
onCycle: () -> Unit, onCycle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val labelRes = when (current) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
}
FilledTonalButton( FilledTonalButton(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier, modifier = modifier,
) { ) {
Text(stringResource(labelRes)) Text(stringResource(current.labelRes))
} }
} }

View File

@@ -157,7 +157,11 @@ fun DayScreen(
gesturesEnabled = drawerState.isOpen, gesturesEnabled = drawerState.isOpen,
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } }, currentView = selectedView,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onSettings = { onSettings = {
onOpenSettings() onOpenSettings()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }

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
@@ -130,8 +131,9 @@ fun MonthScreen(
gesturesEnabled = drawerState.isOpen, gesturesEnabled = drawerState.isOpen,
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
onToday = { currentView = selectedView,
jumpToToday() onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onSettings = { onSettings = {
@@ -177,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,
@@ -192,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,
@@ -237,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,
) )
} }
@@ -307,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) {
DayCard(
date = date,
isToday = date == state.today,
data = state.cells[date],
onClick = { onOpenDay(date) },
modifier = Modifier.weight(1f),
) )
} else {
Spacer(Modifier.weight(1f))
}
}
}
} }
} }
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class) /**
* 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 @Composable
private fun DayCard( private fun MonthWeekRow(
date: LocalDate, week: MonthWeek,
isToday: Boolean, today: LocalDate,
data: DayCellData?, month: YearMonth,
onClick: () -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val todayPrefix = stringResource(R.string.month_a11y_today_prefix) val dark = isSystemInDarkTheme()
val cellLabel = buildString { val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
if (isToday) append(todayPrefix).append(", ") val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
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 BoxWithConstraints(modifier) {
// scheme drives a subtle scale, instead of a fixed easing curve. val colW = maxWidth / 7
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( // Per-day background pills — same surfaceContainer rounded surface the
onClick = onClick, // week/day views use, so the three views share one visual language.
interactionSource = interactionSource, // Spanning bars draw on top of these, bridging cells, so they still read
shape = MaterialTheme.shapes.medium, // as one continuous event.
colors = CardDefaults.cardColors( Row(Modifier.matchParentSize()) {
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer week.days.forEach { d ->
else MaterialTheme.colorScheme.surfaceContainerHigh, val inMonth = d.month == month.month && d.year == month.year
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer Box(
else MaterialTheme.colorScheme.onSurface, 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,
), ),
modifier = modifier )
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
} }
.semantics { contentDescription = cellLabel }, }
) {
Column( 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 modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.padding(top = 4.dp, bottom = 2.dp), .weight(1f)
horizontalAlignment = Alignment.CenterHorizontally, .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) },
)
}
}
}
}
@Composable
private fun DayNumberCell(
date: LocalDate,
isToday: Boolean,
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(
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,
) )

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) } },
)
}
}
} }
/** /**

View File

@@ -162,7 +162,11 @@ fun WeekScreen(
gesturesEnabled = drawerState.isOpen, gesturesEnabled = drawerState.isOpen,
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } }, currentView = selectedView,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onSettings = { onSettings = {
onOpenSettings() onOpenSettings()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }

View File

@@ -187,6 +187,7 @@
<string name="view_month">Monat</string> <string name="view_month">Monat</string>
<string name="view_week">Woche</string> <string name="view_week">Woche</string>
<string name="view_day">Tag</string> <string name="view_day">Tag</string>
<string name="view_section">Ansicht</string>
<!-- Kalender-Filter (M3) --> <!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string> <string name="filter_title">Kalender</string>

View File

@@ -188,6 +188,7 @@
<string name="view_month">Month</string> <string name="view_month">Month</string>
<string name="view_week">Week</string> <string name="view_week">Week</string>
<string name="view_day">Day</string> <string name="view_day">Day</string>
<string name="view_section">View</string>
<!-- Calendar filter (M3) --> <!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string> <string name="filter_title">Calendars</string>