Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d471255340 | ||
|
|
dcb1e891f1 | ||
|
|
7bf41fa0f8 | ||
|
|
a23ea81c48 | ||
|
|
52aa48fda7 | ||
|
|
264b1ebbf4 |
@@ -5,6 +5,11 @@ 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]
|
||||
|
||||
### 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
|
||||
@@ -148,6 +152,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
|
||||
@@ -429,6 +434,7 @@ fun MonthScreen(
|
||||
onCreateEvent(
|
||||
when {
|
||||
viewStyle == MonthViewStyle.Split -> selectedDate
|
||||
?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||
isOnCurrentMonth -> today
|
||||
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||
},
|
||||
@@ -473,6 +479,9 @@ fun MonthScreen(
|
||||
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 +1302,14 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
||||
@Composable
|
||||
private fun SplitMonthContent(
|
||||
state: MonthUiState,
|
||||
selected: LocalDate,
|
||||
selected: LocalDate?,
|
||||
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,
|
||||
@@ -1373,6 +1385,9 @@ private fun SplitMonthContent(
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
expandDrag = expandDrag,
|
||||
onSelectDay = onSelectDay,
|
||||
onPaneSwipeNext = onPaneSwipeNext,
|
||||
onPaneSwipePrev = onPaneSwipePrev,
|
||||
onClearSelection = onClearSelection,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
@@ -1398,10 +1413,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 +1458,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 +1533,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 +1645,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 +1880,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user