Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c770afb3 | ||
|
|
aee23dc751 | ||
|
|
705835a66f | ||
|
|
c2302c033e |
@@ -9,9 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
|
||||
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
|
||||
|
||||
### Changed
|
||||
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
|
||||
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
|
||||
|
||||
## [2.21.0] — 2026-09-23
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
@@ -112,6 +113,11 @@ fun CalendarHost(
|
||||
pendingDayIso = date.toString()
|
||||
viewStack = viewStack.drillToDay()
|
||||
}
|
||||
var pendingWeekIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||
pendingWeekIso = date.toString()
|
||||
viewStack = viewStack.drillToWeek()
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
// it overlays whichever calendar view is active. We forward the tapped
|
||||
@@ -380,6 +386,8 @@ fun CalendarHost(
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
initialDateIso = pendingWeekIso,
|
||||
onInitialDateConsumed = { pendingWeekIso = null },
|
||||
)
|
||||
CalendarView.MultiDay -> MultiDayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -409,6 +417,7 @@ fun CalendarHost(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenWeek = onOpenWeek,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
|
||||
@@ -129,3 +129,7 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||
|
||||
/** Push the week view over the month after a week-number tap. */
|
||||
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||
|
||||
@@ -19,10 +19,12 @@ import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
@@ -81,6 +83,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -89,6 +92,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -107,6 +111,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.abs
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
@@ -200,12 +205,55 @@ import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
||||
private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null }
|
||||
|
||||
/** A brief push from today's full cell into its immediate neighbours. */
|
||||
@Composable
|
||||
private fun rememberTodayRipple(active: Boolean): Animatable<Float, androidx.compose.animation.core.AnimationVector1D> {
|
||||
val pulse = remember { Animatable(0f) }
|
||||
val pulseKey = LocalTodayPulse.current
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
LaunchedEffect(pulseKey, active, reduceMotion) {
|
||||
if (active && pulseKey > 0 && !reduceMotion) {
|
||||
pulse.snapTo(0f)
|
||||
pulse.animateTo(1f, tween(durationMillis = 180))
|
||||
pulse.animateTo(
|
||||
0f,
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
pulse.snapTo(0f)
|
||||
}
|
||||
}
|
||||
return pulse
|
||||
}
|
||||
|
||||
/** Paint-only motion keeps hit targets and the month grid's lane geometry stable. */
|
||||
private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): Modifier {
|
||||
if (relativeColumn == null || abs(relativeColumn) > 1) return this
|
||||
return graphicsLayer {
|
||||
val pulse = progress()
|
||||
if (relativeColumn == 0) {
|
||||
scaleX = 1f + 0.10f * pulse
|
||||
scaleY = 1f + 0.06f * pulse
|
||||
} else {
|
||||
translationX = (if (relativeColumn < 0) -1f else 1f) * 4.dp.toPx() * pulse
|
||||
scaleX = 1f - 0.025f * pulse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MonthScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onOpenWeek: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
@@ -358,7 +406,9 @@ fun MonthScreen(
|
||||
val jumpToMonth: (YearMonth) -> Unit = { ym ->
|
||||
scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
|
||||
}
|
||||
var todayPulse by remember { mutableIntStateOf(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
todayPulse++
|
||||
if (scrolling) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
@@ -466,6 +516,8 @@ fun MonthScreen(
|
||||
CompositionLocalProvider(
|
||||
LocalDimCutoff provides dimCutoff,
|
||||
LocalMonthDrag provides chipDrag,
|
||||
LocalTodayPulse provides todayPulse,
|
||||
LocalOpenWeek provides onOpenWeek,
|
||||
) {
|
||||
if (scrolling) {
|
||||
ContinuousMonthContent(
|
||||
@@ -884,7 +936,7 @@ private fun rememberWeekNumberGutter(): Dp {
|
||||
val style = weekNumberStyle()
|
||||
return remember(style, density, measurer) {
|
||||
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
|
||||
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
|
||||
(text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2).coerceAtLeast(48.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1626,7 +1678,7 @@ private fun SplitExpandHandle(
|
||||
* it's roughly a third of a paged row — which is the point: the space it gives
|
||||
* up goes to the day pane below.
|
||||
*/
|
||||
private val SPLIT_ROW_HEIGHT = 46.dp
|
||||
private val SPLIT_ROW_HEIGHT = 48.dp
|
||||
|
||||
/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */
|
||||
private val SPLIT_GRID_VERTICAL_PADDING = 4.dp
|
||||
@@ -1672,6 +1724,8 @@ internal fun SplitMonthGrid(
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val month = state.month
|
||||
val hasToday = state.today.year == month.year && state.today.month == month.month
|
||||
val ripple = rememberTodayRipple(active = hasToday)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1679,6 +1733,7 @@ internal fun SplitMonthGrid(
|
||||
verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP),
|
||||
) {
|
||||
state.weeks.forEach { week ->
|
||||
val todayCol = week.days.indexOf(state.today).takeIf { hasToday && it >= 0 }
|
||||
Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) {
|
||||
if (showWeekNumbers) {
|
||||
WeekNumberGutter(
|
||||
@@ -1709,6 +1764,8 @@ internal fun SplitMonthGrid(
|
||||
// page transition.
|
||||
isSelected = day == selected && inMonth,
|
||||
inMonth = inMonth,
|
||||
relativeToToday = todayCol?.let { col - it },
|
||||
rippleProgress = { ripple.value },
|
||||
dark = dark,
|
||||
onClick = { onSelectDay(day) },
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
@@ -1735,6 +1792,8 @@ private fun SplitDayCell(
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
inMonth: Boolean,
|
||||
relativeToToday: Int?,
|
||||
rippleProgress: () -> Float,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -1743,6 +1802,7 @@ private fun SplitDayCell(
|
||||
// the whole cell, which read as a second state on top of the day rather than
|
||||
// as a mark on it — and fought today's own circle when they coincided.
|
||||
val background = when {
|
||||
isToday && inMonth -> MaterialTheme.colorScheme.primaryContainer
|
||||
inMonth -> MaterialTheme.colorScheme.surfaceContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||
}
|
||||
@@ -1775,6 +1835,7 @@ private fun SplitDayCell(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.todayRipple(relativeToToday, rippleProgress)
|
||||
.selectable(selected = isSelected, onClick = onClick),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
@@ -2171,6 +2232,8 @@ private fun MonthWeekRow(
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val todayCol = week.days.indexOf(today).takeIf { it >= 0 && inMonth(today) }
|
||||
val ripple = rememberTodayRipple(active = todayCol != null)
|
||||
val overflowRow = rememberOverflowRowHeight()
|
||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
@@ -2290,15 +2353,17 @@ private fun MonthWeekRow(
|
||||
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||
// as one continuous event.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
week.days.forEachIndexed { col, d ->
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.morphElement(MonthMorphKey.Cell(d))
|
||||
.todayRipple(todayCol?.let { col - it }) { ripple.value }
|
||||
.background(
|
||||
color = when {
|
||||
d == today && inMonth(d) -> MaterialTheme.colorScheme.primaryContainer
|
||||
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
|
||||
// A blanked cell draws nothing at all: it is
|
||||
// the gap that shows where the month starts.
|
||||
@@ -2313,7 +2378,7 @@ private fun MonthWeekRow(
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.days.forEach { d ->
|
||||
week.days.forEachIndexed { col, d ->
|
||||
if (blankOutside && !inMonth(d)) {
|
||||
Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT))
|
||||
} else {
|
||||
@@ -2330,7 +2395,8 @@ private fun MonthWeekRow(
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.morphElement(MonthMorphKey.DayNumber(d)),
|
||||
.morphElement(MonthMorphKey.DayNumber(d))
|
||||
.todayRipple(todayCol?.let { col - it }) { ripple.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2445,6 +2511,7 @@ private fun MonthWeekRow(
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.todayRipple(todayCol?.let { col - it }) { ripple.value }
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
@@ -2678,8 +2745,13 @@ private fun monthChipDragModifier(
|
||||
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
val label = stringResource(R.string.week_number_label)
|
||||
val openLabel = stringResource(R.string.month_open_week, weekNumber)
|
||||
val onOpenWeek = LocalOpenWeek.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
|
||||
onOpenWeek(weekStart)
|
||||
} else Modifier)
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||
.semantics { contentDescription = "$label $weekNumber" },
|
||||
|
||||
@@ -199,23 +199,33 @@ fun WeekScreen(
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
initialDateIso: String? = null,
|
||||
onInitialDateConsumed: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) = PagedTimelineScreen(
|
||||
viewModel = viewModel,
|
||||
showWeekNumber = true,
|
||||
selectedView = selectedView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
modifier = modifier,
|
||||
)
|
||||
) {
|
||||
LaunchedEffect(initialDateIso) {
|
||||
initialDateIso?.let {
|
||||
viewModel.goToDate(LocalDate.parse(it))
|
||||
onInitialDateConsumed()
|
||||
}
|
||||
}
|
||||
PagedTimelineScreen(
|
||||
viewModel = viewModel,
|
||||
showWeekNumber = true,
|
||||
selectedView = selectedView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** The multi-day view (#193): the week view's timeline over the day count from Settings. */
|
||||
@Composable
|
||||
|
||||
@@ -150,6 +150,7 @@ abstract class PagedTimelineViewModel(
|
||||
}
|
||||
|
||||
fun goToToday() = jumpTo(todayDate)
|
||||
fun goToDate(date: LocalDate) = jumpTo(date)
|
||||
|
||||
private fun buildState(
|
||||
span: DaySpan,
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<!-- Month view (S1) -->
|
||||
<string name="month_prev">Previous month</string>
|
||||
<string name="month_next">Next month</string>
|
||||
<string name="month_open_week">Open week %1$d</string>
|
||||
<string name="month_today_action">Today</string>
|
||||
<string name="month_split_clear_filter">Show full month agenda</string>
|
||||
<string name="month_more_actions">More actions</string>
|
||||
|
||||
@@ -65,6 +65,14 @@ class ViewBackStackTest {
|
||||
assertThat(stack.drillToDay()).isEqualTo(stack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week number drills into week and back returns to month`() {
|
||||
val stack = listOf(CalendarView.Month).drillToWeek()
|
||||
assertThat(stack).containsExactly(CalendarView.Month, CalendarView.Week).inOrder()
|
||||
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
|
||||
assertThat(stack.drillToWeek()).isEqualTo(stack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda widget date tap backs out agenda then home`() {
|
||||
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
|
||||
|
||||
Reference in New Issue
Block a user