Compare commits

..
5 changed files with 206 additions and 59 deletions
+8
View File
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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]).
### 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]).
## [2.21.0] — 2026-09-23
### Added
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterExitState
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
@@ -55,10 +56,12 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
@@ -66,6 +69,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
@@ -131,6 +135,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -148,6 +154,7 @@ import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
@@ -327,6 +334,9 @@ fun MonthScreen(
}
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
var splitExpanded by rememberSaveable(viewStyle) {
mutableStateOf(viewStyle == MonthViewStyle.Paged)
}
// Continuous names the month on the block's own sticky header, so the bar
// carries the year instead of repeating it two lines further down. Dense has
@@ -428,7 +438,9 @@ fun MonthScreen(
// today when its month is shown, else the 1st.
onCreateEvent(
when {
viewStyle == MonthViewStyle.Split -> selectedDate
(viewStyle == MonthViewStyle.Split ||
(viewStyle == MonthViewStyle.Paged && !splitExpanded)) -> selectedDate
?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1)
isOnCurrentMonth -> today
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
},
@@ -465,14 +477,20 @@ fun MonthScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} else if (viewStyle == MonthViewStyle.Split) {
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) {
SplitMonthContent(
state = state,
selected = selectedDate,
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
expanded = splitExpanded,
onExpandedChange = { splitExpanded = it },
pagerState = pagerState,
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers,
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
onClearSelection = viewModel::clearSelection,
onRetry = jumpToToday,
// A tapped leading/trailing day follows to its own
// month, pager and all — straight away, or a swipe
@@ -1293,11 +1311,17 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
@Composable
private fun SplitMonthContent(
state: MonthUiState,
selected: LocalDate,
selected: LocalDate?,
initiallyExpanded: Boolean,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean,
onPaneSwipeNext: () -> Unit,
onPaneSwipePrev: () -> Unit,
onClearSelection: () -> Unit,
onRetry: () -> Unit,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -1306,15 +1330,23 @@ private fun SplitMonthContent(
) {
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
var expanded by rememberSaveable { mutableStateOf(false) }
// Back collapses before it does anything else. Expanding replaces the whole
// screen, so it is a place you can be, and every other place in the app can
// be backed out of. Declared deeper than CalendarHost's view-stack handler,
// which is what makes it win while it is enabled.
BackHandler(enabled = expanded) { expanded = false }
val haptics = LocalHapticFeedback.current
BackHandler(enabled = expanded != initiallyExpanded) {
onExpandedChange(initiallyExpanded)
}
val expandDrag = rememberSplitExpandDrag(
onExpand = { expanded = true },
onCollapse = { expanded = false },
onExpand = {
if (!expanded) {
onExpandedChange(true)
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
},
onCollapse = {
if (expanded) {
onExpandedChange(false)
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
},
)
when (state) {
@@ -1357,13 +1389,13 @@ private fun SplitMonthContent(
// the day you picked.
onPickDay = {
onSelectDay(it)
expanded = false
onExpandedChange(false)
},
// A tapped chip is asking for that event, not for its
// day, so it opens the detail from here too rather
// than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { expanded = false },
onCollapse = { onExpandedChange(false) },
)
} else {
SplitMonthCollapsed(
@@ -1373,10 +1405,13 @@ private fun SplitMonthContent(
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
onSelectDay = onSelectDay,
onPaneSwipeNext = onPaneSwipeNext,
onPaneSwipePrev = onPaneSwipePrev,
onClearSelection = onClearSelection,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { expanded = true },
onExpand = { onExpandedChange(true) },
)
}
}
@@ -1398,10 +1433,13 @@ private class SplitMonths(
private fun SplitMonthCollapsed(
state: MonthUiState.Success,
months: SplitMonths,
selected: LocalDate,
selected: LocalDate?,
showWeekNumbers: Boolean,
expandDrag: Modifier,
onSelectDay: (LocalDate) -> Unit,
onPaneSwipeNext: () -> Unit,
onPaneSwipePrev: () -> Unit,
onClearSelection: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
@@ -1440,18 +1478,64 @@ private fun SplitMonthCollapsed(
}
SplitExpandHandle(expanded = false, onToggle = onExpand)
}
SplitDayPane(
date = selected,
today = state.today,
// Null, not empty: the selection moves to the new month before
// its data arrives, and a missing key means "not loaded yet".
// Passing an empty list would claim the day was free.
events = state.instancesByDay[selected],
zone = state.zone,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
modifier = Modifier.weight(1f).fillMaxWidth(),
Box(
Modifier.weight(1f).fillMaxWidth()
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
) {
if (selected == null) {
SplitMonthAgendaPane(
state = state,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
)
} else {
Column {
InputChip(
selected = true,
onClick = onClearSelection,
label = { Text(agendaDayLabel(selected, state.today)) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.month_split_clear_filter),
)
},
modifier = Modifier.padding(start = 16.dp),
)
SplitDayPane(
date = selected,
today = state.today,
events = state.instancesByDay[selected],
zone = state.zone,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
}
}
}
}
}
@Composable
private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modifier {
val threshold = with(LocalDensity.current) { 24.dp.toPx() }
val currentNext by rememberUpdatedState(onNext)
val currentPrev by rememberUpdatedState(onPrev)
return Modifier.pointerInput(threshold) {
var distance = 0f
var fired = false
detectHorizontalDragGestures(
onDragStart = { distance = 0f; fired = false },
onHorizontalDrag = { _, drag ->
distance += drag
if (!fired && kotlin.math.abs(distance) >= threshold) {
fired = true
if (distance < 0f) currentNext() else currentPrev()
}
},
)
}
}
@@ -1469,7 +1553,7 @@ private fun SplitMonthCollapsed(
private fun SplitMonthExpanded(
months: SplitMonths,
/** Marked nowhere here; it only anchors the outline's morph. */
selected: LocalDate,
selected: LocalDate?,
showWeekNumbers: Boolean,
expandDrag: Modifier,
onPickDay: (LocalDate) -> Unit,
@@ -1581,7 +1665,7 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
@Composable
internal fun SplitMonthGrid(
state: MonthUiState.Success,
selected: LocalDate,
selected: LocalDate?,
showWeekNumbers: Boolean,
onSelectDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
@@ -1816,6 +1900,54 @@ private fun SplitDots(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SplitMonthAgendaPane(
state: MonthUiState.Success,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
val start = agendaStartForMonth(state.month, state.today)
val days = state.instancesByDay.keys
.filter { it >= start && it.year == state.month.year && it.month == state.month.month }
.filter { state.instancesByDay[it].orEmpty().isNotEmpty() }
.toMutableList()
.apply { if (start !in this) add(start) }
.sorted()
val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
days.forEach { day ->
val events = state.instancesByDay[day].orEmpty()
stickyHeader(key = "header-$day") {
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay)
}
if (events.isEmpty()) {
item(key = "empty-$day") {
AgendaEmptyDayRow(
text = stringResource(R.string.month_split_no_events),
onClick = { onCreateEvent(day) },
)
}
} else {
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
AgendaEventRow(
event = event,
day = day,
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
}
}
}
/**
* The selected day's events, in the agenda's own row vocabulary so the two
* surfaces read as one app. The date header opens the full Day view, matching
@@ -292,12 +292,10 @@ class MonthViewModel @Inject constructor(
// --- Split style (#53) ------------------------------------------------
//
// The first selected-day concept in the app: the other views drill straight
// into a date, while the split style keeps one selected and lists it below
// the grid.
// Null means the full-month agenda; a date narrows the pane to that day.
private val _selectedDate = MutableStateFlow(todayDate)
val selectedDate: StateFlow<LocalDate> = _selectedDate
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
val selectedDate: StateFlow<LocalDate?> = _selectedDate
/**
* Select [date], following it to its month if it sits in the grid's leading
@@ -305,23 +303,33 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at.
*/
fun selectDate(date: LocalDate) {
_selectedDate.value = date
val target = YearMonth(date.year, date.month)
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target
}
/**
* Move the selection with the month: today if the new month holds it, else
* its 1st. Leaving the old date selected would list a day the grid no longer
* shows.
*/
fun clearSelection() { _selectedDate.value = null }
/** A pane swipe walks days when filtered, months when showing the agenda. */
fun stepSplitPane(days: Int) {
val selected = _selectedDate.value
if (selected == null) {
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
} else {
val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next
_month.value = YearMonth(next.year, next.month)
}
}
/** Moving the grid to another month restores its full agenda. */
private fun realignSelection() {
_selectedDate.value = selectionForMonth(_month.value, todayDate)
_selectedDate.value = null
}
fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month)
_selectedDate.value = todayDate
_selectedDate.value = null
}
/**
@@ -330,13 +338,12 @@ class MonthViewModel @Inject constructor(
* otherwise sit wherever paged navigation last left it (today's month, on a
* fresh open) — and a switch to a paged style, or a reseed of the other
* scrolling style's list, would jump there instead of holding position. The
* selection is realigned alongside it so that landing on the Split style
* shows a live day in the month on screen rather than a stale, off-month one.
* selection is cleared alongside it so Split opens the visible month's agenda.
*/
fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return
_month.value = ym
_selectedDate.value = selectionForMonth(ym, todayDate)
_selectedDate.value = null
}
/** Jump to the month containing [date] (drawer jump-to-date). */
@@ -544,11 +551,10 @@ private const val WINDOW_EDGE = 2
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
/**
* Which day the split style should select when the grid lands on [month]:
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
* tested without standing up a view model and a provider behind it.
* First day shown in the split agenda: today in the current month, otherwise
* the first of the visible month.
*/
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) {
today
} else {
+6 -5
View File
@@ -1,10 +1,10 @@
<resources>
<string name="app_name">Calendula</string>
<string name="app_tagline">A modern calendar.</string>
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
translatable="false": this string becomes a launcher component's label, so
it must never be changed by an unreviewed community translation. -->
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
<!-- Alternative launcher label offered by the "App name" setting (issue #44),
shown under the app icon. Translate it as the plain, generic word your
language uses for a calendar app (e.g. "Kalender"), nothing else (#251). -->
<string name="app_name_calendar_alias">Calendar</string>
<!-- Loading / Failure / Success generic strings (used across screens) -->
<string name="state_loading">Loading…</string>
@@ -55,6 +55,7 @@
<string name="month_prev">Previous month</string>
<string name="month_next">Next month</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>
<string name="month_open_menu">Open menu</string>
<string name="month_action_settings">Settings</string>
@@ -550,7 +551,7 @@
<string name="settings_month_header">Month view</string>
<string name="settings_month_view_style">Month view style</string>
<string name="month_style_paged">Pages</string>
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month, or swipe up to show the month agenda.</string>
<string name="month_style_continuous">Scrolling months</string>
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
<string name="month_style_dense">Seamless weeks</string>
@@ -222,17 +222,17 @@ class ContinuousMonthIndexTest {
}
@Test
fun `the split selection follows the month, landing on today when it's there`() {
fun `the split agenda starts at today in the current month`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
assertThat(agendaStartForMonth(jun26, today)).isEqualTo(today)
}
@Test
fun `the split selection falls to the 1st of any other month`() {
fun `the split agenda starts at the 1st of another month`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
assertThat(agendaStartForMonth(YearMonth(2026, Month.JULY), today))
.isEqualTo(LocalDate(2026, 7, 1))
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
}