feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
Showing only changes of commit f9619e74d9 - Show all commits

View File

@@ -2,10 +2,14 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -102,6 +106,8 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.itemEnter
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -847,19 +853,25 @@ private fun SplitMonthContent(
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) { is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
AnimatedContent( AnimatedContent(
targetState = state, // The selection travels *with* the state so each page keeps its
// own. Read from outside, both pages would show the incoming
// one, and paging visibly threw the marker across the outgoing
// grid — onto the new month's 1st, which the old grid still
// shows among its trailing days — before the new page arrived.
targetState = state to selected,
modifier = swipeModifier, modifier = swipeModifier,
// Keyed on the month alone, so a provider notification refreshing // Keyed on the month alone, so a provider notification refreshing
// the month you are on updates in place instead of sliding. // the month you are on — or a tap moving the selection within it
contentKey = { it.month }, // — updates in place instead of sliding.
contentKey = { (s, _) -> s.month },
transitionSpec = { transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
}, },
label = "split-month-transition", label = "split-month-transition",
) { s -> ) { (s, sel) ->
SplitMonthGrid( SplitMonthGrid(
state = s, state = s,
selected = selected, selected = sel,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay, onSelectDay = onSelectDay,
) )
@@ -981,17 +993,25 @@ private fun SplitDayCell(
inMonth -> MaterialTheme.colorScheme.surfaceContainer inMonth -> MaterialTheme.colorScheme.surfaceContainer
else -> MaterialTheme.colorScheme.surfaceContainerLow else -> MaterialTheme.colorScheme.surfaceContainerLow
} }
// Each cell fades its own outline, so moving the selection reads as one
// mark crossing the grid rather than as an outline blinking out here and
// reappearing there. Snapped under reduced motion.
val reduceMotion = rememberReduceMotion()
val fadeSpec = rememberCalendarFadeSpec()
val selection by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = if (reduceMotion) snap() else fadeSpec,
label = "split-day-selection",
)
Box( Box(
modifier = modifier modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE) .clip(CELL_SHAPE)
.background(background) .background(background)
.then( .border(
if (isSelected) { width = 1.5.dp,
Modifier.border(1.5.dp, MaterialTheme.colorScheme.primary, CELL_SHAPE) color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
} else { shape = CELL_SHAPE,
Modifier
},
) )
.selectable(selected = isSelected, onClick = onClick), .selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
@@ -1058,39 +1078,58 @@ internal fun SplitDayPane(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
// A day's list used to be replaced outright, so changing day popped one set
// of rows out and another in. The incoming list rises as it fades, which is
// the same entrance the rest of the family uses for resolved content.
val enter = itemEnter()
val fadeSpec = rememberCalendarFadeSpec()
Column(modifier = modifier) { Column(modifier = modifier) {
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay) AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
if (events == null) { AnimatedContent(
// The day isn't in the loaded grid yet — paging lands the selection targetState = date to events,
// on the new month before its events arrive. "Nothing scheduled" modifier = Modifier.weight(1f).fillMaxWidth(),
// here would be a claim about the day rather than about the wait. // Whether the day is still loading is part of the identity, so the
SplitPaneSkeleton() // skeleton hands over to the real list with the same transition.
} else if (events.isEmpty()) { contentKey = { (d, e) -> d to (e == null) },
AgendaEmptyDayRow( transitionSpec = { enter togetherWith fadeOut(fadeSpec) },
label = "split-day-pane",
) { (day, dayEvents) ->
when {
// The day isn't in the loaded grid yet — paging lands the
// selection on the new month before its events arrive. "Nothing
// scheduled" here would be a claim about the day rather than
// about the wait.
dayEvents == null -> SplitPaneSkeleton()
dayEvents.isEmpty() -> AgendaEmptyDayRow(
text = stringResource(R.string.month_split_no_events), text = stringResource(R.string.month_split_no_events),
onClick = { onCreateEvent(date) }, onClick = { onCreateEvent(day) },
) )
} else { else -> LazyColumn(
LazyColumn( // Bottom inset clears the FAB stack so the last row stays
// Bottom inset clears the FAB stack so the last row stays tappable. // tappable.
contentPadding = PaddingValues(bottom = 96.dp), contentPadding = PaddingValues(bottom = 96.dp),
) { ) {
itemsIndexed( itemsIndexed(
items = events, items = dayEvents,
key = { _, event -> event.instanceId }, key = { _, event -> event.instanceId },
) { index, event -> ) { index, event ->
AgendaEventRow( AgendaEventRow(
event = event, event = event,
day = date, day = day,
zone = zone, zone = zone,
position = positionOf(index, events.size), position = positionOf(index, dayEvents.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff), dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
// An event added to or removed from the day you are
// already looking at moves its neighbours rather
// than teleporting them.
modifier = animateItemMotion(),
) )
} }
} }
} }
} }
}
} }
/** Stand-in rows for a day pane whose month hasn't arrived yet. */ /** Stand-in rows for a day pane whose month hasn't arrived yet. */