refactor(week): polish timeline — rounded viewport, scroll persistence, week badge
All checks were successful
CI / ci (push) Successful in 11m40s

- Rounded, permanently-soft day-column scroll viewport via two viewports
  sharing one scroll state (gutter + columns stay aligned); plain
  rectangular column cards inside
- Vertical scroll position now persists across week swipes; noon-centring
  only runs on first entry into the week view (from month/day)
- All-day strip height is hoisted + animated, shared by both swipe pages,
  so it slides along and resizes smoothly instead of jumping
- Multi-line event time label so the end time isn't clipped in narrow
  columns; hour labels centred in the gutter
- Calendar-week (ISO) badge in the header gutter, aligned with the date
  numbers; dropped the redundant "All-day" gutter label
- Small breathing room between the top section and the timeline

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:04:08 +02:00
parent 6a90bade8a
commit 94fa206e2e
3 changed files with 125 additions and 63 deletions

View File

@@ -3,7 +3,7 @@ package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -23,6 +23,7 @@ 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.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -57,7 +58,9 @@ 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.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
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
@@ -65,8 +68,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription 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.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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
@@ -94,6 +97,14 @@ private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp private val GUTTER_WIDTH = 48.dp
private val MIN_EVENT_HEIGHT = 24.dp private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for a week (0 when there are no all-day events). */
private fun WeekUiState.Success.allDayStripHeight(): Dp {
if (allDaySpans.isEmpty()) return 0.dp
val lanes = allDaySpans.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -200,6 +211,29 @@ private fun WeekContent(
var dragAccum by remember { mutableFloatStateOf(0f) } var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
// noon once, on first entry into the week view (i.e. when arriving from the
// month/day view), not on every swipe.
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue
val target = with(density) {
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// week during a swipe, so the strip slides along but never jumps in height;
// it just springs smoothly from the old to the new size.
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "all-day-strip-height",
)
// Whole-page horizontal swipe. It sits one level above the timeline's // Whole-page horizontal swipe. It sits one level above the timeline's
// vertical scroll: a horizontal drag only crosses *this* detector's slop, // vertical scroll: a horizontal drag only crosses *this* detector's slop,
// while a vertical drag is consumed by the inner scroll first — so the two // while a vertical drag is consumed by the inner scroll first — so the two
@@ -235,27 +269,36 @@ private fun WeekContent(
when (s) { when (s) {
WeekUiState.Loading -> WeekLoading() WeekUiState.Loading -> WeekLoading()
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(state = s, topSectionColor = topSectionColor) is WeekUiState.Success -> WeekSuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
)
} }
} }
} }
@Composable @Composable
private fun WeekSuccess(state: WeekUiState.Success, topSectionColor: Color) { private fun WeekSuccess(
state: WeekUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(topSectionColor) .background(topSectionColor),
.animateContentSize(),
) { ) {
WeekDayHeader(days = state.days, today = state.today) WeekDayHeader(days = state.days, today = state.today)
AllDayStrip(state = state) AllDayStrip(state = state, height = allDayHeight)
} }
// Breathing room between the (colour-shifting) top section and the // Breathing room between the (colour-shifting) top section and the
// scrolling timeline below. // scrolling timeline below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline(state = state) Timeline(state = state, scrollState = scrollState)
} }
} }
@@ -303,12 +346,26 @@ private fun WeekTopBar(
@Composable @Composable
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) { private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
val locale = currentLocale() val locale = currentLocale()
val weekStart = days.first()
val weekNumber = remember(weekStart) {
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp), .padding(top = 4.dp, bottom = 8.dp),
) { ) {
Spacer(Modifier.width(GUTTER_WIDTH)) // Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers.
Column(
modifier = Modifier.width(GUTTER_WIDTH),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
WeekNumberBadge(weekNumber = weekNumber)
}
days.forEach { date -> days.forEach { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today val isToday = date == today
@@ -355,35 +412,48 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
} }
} }
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number. */
@Composable @Composable
private fun AllDayStrip(state: WeekUiState.Success) { private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
if (state.allDaySpans.isEmpty()) return val label = stringResource(R.string.week_number_label)
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = modifier.semantics { contentDescription = "$label $weekNumber" },
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@Composable
private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val lanes = state.allDaySpans.maxOf { it.lane } + 1
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 6.dp), // Height is hoisted + animated so it slides and resizes smoothly;
// padding sits inside it so the content area is lanes * row height.
.height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
) { ) {
Box( // Keep the gutter-width offset so the bars line up with the day columns.
modifier = Modifier.width(GUTTER_WIDTH), Spacer(Modifier.width(GUTTER_WIDTH))
contentAlignment = Alignment.TopEnd,
) {
Text(
text = stringResource(R.string.week_all_day),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.padding(top = 2.dp, end = 4.dp),
)
}
// Span bars are positioned absolutely so a multi-day event is one // Span bars are positioned absolutely so a multi-day event is one
// connected bar across columns rather than a chip per day. // connected bar across columns rather than a chip per day. clipToBounds
// keeps bars from spilling out while the height animates.
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(ALL_DAY_ROW_HEIGHT * lanes), .fillMaxHeight()
.clipToBounds(),
) { ) {
val colWidth = maxWidth / 7 val colWidth = maxWidth / 7
state.allDaySpans.forEach { span -> state.allDaySpans.forEach { span ->
@@ -426,39 +496,23 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
} }
@Composable @Composable
private fun Timeline(state: WeekUiState.Success) { private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
val totalHeight = HOUR_HEIGHT * 24 val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val density = LocalDensity.current
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
val scrollState = rememberScrollState() // Gutter and day columns are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// Center the timeline on noon once the content is measured. Deriving the // static, rounded-clipped window — the content scrolls inside it, so the
// target from maxValue (known after layout) is reliable, unlike reading // soft corners are permanent at any scroll position (not just at the
// the viewport height during the first composition. // day's start/end).
LaunchedEffect(scrollState) { Row(modifier = Modifier.fillMaxSize()) {
snapshotFlow { scrollState.maxValue }.first { it > 0 } // Hour gutter (scrolls in sync with the day columns)
val maxV = scrollState.maxValue
val target = with(density) {
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
}
// One scroll container for the whole timeline. Gutter and day columns
// are siblings inside it, so they share the exact same scroll position
// and can never drift apart.
Row(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState),
) {
// Hour gutter
Column( Column(
modifier = Modifier modifier = Modifier
.width(GUTTER_WIDTH) .width(GUTTER_WIDTH)
.height(totalHeight), .fillMaxHeight()
.verticalScroll(scrollState),
) { ) {
(0 until 24).forEach { h -> (0 until 24).forEach { h ->
Box( Box(
@@ -479,16 +533,18 @@ private fun Timeline(state: WeekUiState.Success) {
} }
} }
} }
// Day columns bundled in a rounded container so the whole block has // Day columns: rounded, clipped scroll viewport (permanent corners).
// soft corners (rounded at the day's start and end).
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(totalHeight) .fillMaxHeight()
.clip(RoundedCornerShape(16.dp)), .clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
) { ) {
Row( Row(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
state.days.forEach { day -> state.days.forEach { day ->
@@ -513,7 +569,9 @@ private fun DayColumnCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Card( Card(
shape = MaterialTheme.shapes.small, // Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
@@ -559,15 +617,19 @@ private fun EventBlock(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 2 else 1, maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f), color = Color.Black.copy(alpha = 0.85f),
) )
if (showTime) { if (showTime) {
// Narrow columns can't fit "13:0014:00" on one line, so let it
// wrap to a second line (after the dash) instead of clipping the
// end time.
Text( Text(
text = timeLabel, text = timeLabel,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f), color = Color.Black.copy(alpha = 0.6f),
) )
} }

View File

@@ -32,8 +32,8 @@
<string name="month_a11y_today_prefix">Heute</string> <string name="month_a11y_today_prefix">Heute</string>
<!-- Wochenansicht (S2) --> <!-- Wochenansicht (S2) -->
<string name="week_all_day">Ganztägig</string>
<string name="week_today_action">Diese Woche</string> <string name="week_today_action">Diese Woche</string>
<string name="week_number_label">KW</string>
<!-- Geteilte Event-Strings --> <!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string> <string name="event_untitled">(Ohne Titel)</string>

View File

@@ -33,8 +33,8 @@
<string name="month_a11y_today_prefix">Today</string> <string name="month_a11y_today_prefix">Today</string>
<!-- Week view (S2) --> <!-- Week view (S2) -->
<string name="week_all_day">All-day</string>
<string name="week_today_action">This week</string> <string name="week_today_action">This week</string>
<string name="week_number_label">Wk</string>
<!-- Shared event strings --> <!-- Shared event strings -->
<string name="event_untitled">(No title)</string> <string name="event_untitled">(No title)</string>