diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
index b9f70dd..4ee8200 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
@@ -8,12 +8,13 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -60,16 +61,18 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow
-import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
@@ -124,6 +127,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
+import kotlin.math.abs
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@@ -573,6 +577,12 @@ internal fun MonthGrid(
state: MonthUiState.Success,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
+ /**
+ * Outlined when set, matching the compact grid's marker. Only the split
+ * style's expanded form passes one — the paged style has no selection, and
+ * marking a day there would invent a state it doesn't have (#53).
+ */
+ selected: LocalDate? = null,
) {
Column(
modifier = Modifier
@@ -591,6 +601,7 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
+ selected = selected,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
@@ -781,35 +792,79 @@ internal fun DenseMonthGrid(
}
}
+/** Which way a drag went, decided once per gesture and then held. */
+private enum class DragAxis { Undecided, Horizontal, Vertical }
+
/**
- * The month-changing horizontal swipe, shared by the paged and split styles.
- * Accumulates the drag and commits past a threshold on release — the grid
- * doesn't follow the finger, so there is no distance to rubber-band against.
+ * The month grid's drag gesture: horizontal pages the month, vertical expands or
+ * collapses the split style (#53). Accumulates and commits past a threshold on
+ * release — the grid doesn't follow the finger, so there is no distance to
+ * rubber-band against.
*
- * The threshold matches the week and day views'. It used to be 6dp, which is
- * inside the distance a tap wanders: brushing the grid changed the month, and a
- * page that turns on an unintended gesture reads as the animation misfiring
- * rather than as the gesture being over-eager.
+ * The axis is **locked on the first movement and held for the whole gesture**, so
+ * a drag can page or expand but never both. Two independent detectors on one
+ * surface would each see their own component of a diagonal drag and both fire.
+ *
+ * The horizontal threshold matches the week and day views'. It used to be 6dp,
+ * which is inside the distance a tap wanders: brushing the grid changed the
+ * month, and a page that turns on an unintended gesture reads as the animation
+ * misfiring rather than as the gesture being over-eager. The vertical one is
+ * larger — swapping the whole layout out deserves a more deliberate pull than
+ * stepping to the next month.
+ *
+ * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical
+ * axis unclaimed: the lock still happens, so a vertical drag there does nothing
+ * rather than being re-read as a page turn.
*/
@Composable
private fun rememberMonthSwipeModifier(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
+ onExpand: (() -> Unit)? = null,
+ onCollapse: (() -> Unit)? = null,
): Modifier {
- val threshold = with(LocalDensity.current) { MONTH_SWIPE_THRESHOLD.toPx() }
- var dragAccum by remember { mutableFloatStateOf(0f) }
- return Modifier.pointerInput(Unit) {
- detectHorizontalDragGestures(
- onDragStart = { dragAccum = 0f },
- onDragEnd = {
- when {
- dragAccum < -threshold -> onSwipeNext()
- dragAccum > threshold -> onSwipePrev()
- }
- dragAccum = 0f
+ val density = LocalDensity.current
+ val pageThreshold = with(density) { MONTH_SWIPE_THRESHOLD.toPx() }
+ val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() }
+ return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
+ var accum = Offset.Zero
+ var axis = DragAxis.Undecided
+ detectDragGestures(
+ onDragStart = {
+ accum = Offset.Zero
+ axis = DragAxis.Undecided
+ },
+ onDragEnd = {
+ when (axis) {
+ DragAxis.Horizontal -> when {
+ accum.x < -pageThreshold -> onSwipeNext()
+ accum.x > pageThreshold -> onSwipePrev()
+ }
+ DragAxis.Vertical -> when {
+ accum.y > expandThreshold -> onExpand?.invoke()
+ accum.y < -expandThreshold -> onCollapse?.invoke()
+ }
+ DragAxis.Undecided -> Unit
+ }
+ accum = Offset.Zero
+ axis = DragAxis.Undecided
+ },
+ onDragCancel = {
+ accum = Offset.Zero
+ axis = DragAxis.Undecided
+ },
+ onDrag = { _, drag ->
+ accum += drag
+ if (axis == DragAxis.Undecided) {
+ // Ties go horizontal, keeping paging the default reading of an
+ // ambiguous drag as it was before the vertical axis existed.
+ axis = if (abs(accum.x) >= abs(accum.y)) {
+ DragAxis.Horizontal
+ } else {
+ DragAxis.Vertical
+ }
+ }
},
- onDragCancel = { dragAccum = 0f },
- onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
}
@@ -817,14 +872,24 @@ private fun rememberMonthSwipeModifier(
/** Drag distance that commits a month change, matching the week and day views. */
private val MONTH_SWIPE_THRESHOLD = 24.dp
+/** Drag distance that commits an expand/collapse — deliberately longer than a page. */
+private val MONTH_EXPAND_THRESHOLD = 48.dp
+
/**
* Split style content: the compact grid keeps the month swipe, the pane below it
- * lists whatever day is selected.
+ * lists whatever day is selected — and a downward drag trades the pane away for
+ * the full paged grid, an upward one brings it back (#53).
*
* The grid slides between months like the paged style, which it can only do
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
* stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on
* top of swapping the grid — the pane now holds still and only the grid moves.
+ *
+ * Expansion is deliberately **not** a stored preference. It is a way to look at
+ * the month you are on, not a fourth style; persisted, someone would expand it
+ * once and later find their Split style permanently changed with nothing on
+ * screen to explain why. [rememberSaveable] carries it across a rotation, which
+ * is as long as it should live.
*/
@Composable
private fun SplitMonthContent(
@@ -840,56 +905,196 @@ private fun SplitMonthContent(
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
- val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
- val reduceMotion = rememberReduceMotion()
+ var expanded by rememberSaveable { mutableStateOf(false) }
// The swipe wraps the grid rather than living inside it: mid-transition
// there are two grids, and the gesture belongs to neither. The pane is left
// out of it — it scrolls and is full of tappable rows.
- val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev)
+ val swipeModifier = rememberMonthSwipeModifier(
+ onSwipeNext = onSwipeNext,
+ onSwipePrev = onSwipePrev,
+ onExpand = { expanded = true },
+ onCollapse = { expanded = false },
+ )
when (state) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
- is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
- AnimatedContent(
- // 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,
- // Keyed on the month alone, so a provider notification refreshing
- // the month you are on — or a tap moving the selection within it
- // — updates in place instead of sliding.
- contentKey = { (s, _) -> s.month },
- transitionSpec = {
- calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
- },
- label = "split-month-transition",
- ) { (s, sel) ->
- SplitMonthGrid(
- state = s,
- selected = sel,
+ is MonthUiState.Success -> AnimatedContent(
+ targetState = expanded,
+ modifier = Modifier.fillMaxSize(),
+ // Both branches fill the same box — the grid grows into exactly the
+ // room the pane gives up — so there is no size change to contain.
+ transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
+ label = "split-expand-transition",
+ ) { isExpanded ->
+ if (isExpanded) {
+ SplitMonthExpanded(
+ state = state,
+ selected = selected,
+ slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
+ swipeModifier = swipeModifier,
+ // A tap in the expanded grid picks the day and drops back,
+ // which gives the expanded month a job — a chooser you dip
+ // into — rather than a mode you can get stranded in. The
+ // collapse then runs with the selection already set, so the
+ // pane arrives showing the day you picked.
+ onPickDay = {
+ onSelectDay(it)
+ expanded = false
+ },
+ onCollapse = { expanded = false },
+ )
+ } else {
+ SplitMonthCollapsed(
+ state = state,
+ selected = selected,
+ slideDir = slideDir,
+ showWeekNumbers = showWeekNumbers,
+ swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
+ onOpenDay = onOpenDay,
+ onEventClick = onEventClick,
+ onCreateEvent = onCreateEvent,
+ onExpand = { expanded = true },
)
}
- 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(),
+ }
+ }
+}
+
+/** The split style at rest: compact grid, handle, then the selected day's events. */
+@Composable
+private fun SplitMonthCollapsed(
+ state: MonthUiState.Success,
+ selected: LocalDate,
+ slideDir: Int,
+ showWeekNumbers: Boolean,
+ swipeModifier: Modifier,
+ onSelectDay: (LocalDate) -> Unit,
+ onOpenDay: (LocalDate) -> Unit,
+ onEventClick: (EventInstance) -> Unit,
+ onCreateEvent: (LocalDate) -> Unit,
+ onExpand: () -> Unit,
+) {
+ val slideSpec = rememberCalendarSlideSpec()
+ val fadeSpec = rememberCalendarFadeSpec()
+ val reduceMotion = rememberReduceMotion()
+
+ Column(Modifier.fillMaxSize()) {
+ AnimatedContent(
+ // 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,
+ // Keyed on the month alone, so a provider notification refreshing
+ // the month you are on — or a tap moving the selection within it
+ // — updates in place instead of sliding.
+ contentKey = { (s, _) -> s.month },
+ transitionSpec = {
+ calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
+ },
+ label = "split-month-transition",
+ ) { (s, sel) ->
+ SplitMonthGrid(
+ state = s,
+ selected = sel,
+ showWeekNumbers = showWeekNumbers,
+ onSelectDay = onSelectDay,
)
}
+ 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(),
+ )
+ }
+}
+
+/**
+ * The split style pulled open: the pane is gone and the month gets the whole
+ * screen in the paged style's own vocabulary — real event bars and pills instead
+ * of dots. The handle stays, now at the foot of the grid, to pull it back.
+ */
+@Composable
+private fun SplitMonthExpanded(
+ state: MonthUiState.Success,
+ selected: LocalDate,
+ slideDir: Int,
+ showWeekNumbers: Boolean,
+ swipeModifier: Modifier,
+ onPickDay: (LocalDate) -> Unit,
+ onCollapse: () -> Unit,
+) {
+ val slideSpec = rememberCalendarSlideSpec()
+ val fadeSpec = rememberCalendarFadeSpec()
+ val reduceMotion = rememberReduceMotion()
+
+ Column(Modifier.fillMaxSize()) {
+ AnimatedContent(
+ targetState = state to selected,
+ modifier = Modifier.weight(1f).then(swipeModifier),
+ contentKey = { (s, _) -> s.month },
+ transitionSpec = {
+ calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
+ },
+ label = "split-expanded-month-transition",
+ ) { (s, sel) ->
+ MonthGrid(
+ state = s,
+ showWeekNumbers = showWeekNumbers,
+ onOpenDay = onPickDay,
+ selected = sel,
+ )
+ }
+ SplitExpandHandle(expanded = true, onToggle = onCollapse)
+ }
+}
+
+/**
+ * The grab handle at the seam between grid and pane — the same M3 drag-handle
+ * pill a bottom sheet uses, for the same reason: it advertises that the surface
+ * moves.
+ *
+ * The drag itself lives on the grid, not here. This exists so the gesture is
+ * findable at all, and it takes taps too — a hidden swipe is no use to someone
+ * who never tries it, or who can't make the gesture.
+ */
+@Composable
+private fun SplitExpandHandle(
+ expanded: Boolean,
+ onToggle: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val label = stringResource(
+ if (expanded) R.string.month_split_collapse else R.string.month_split_expand,
+ )
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(SPLIT_HANDLE_ROW_HEIGHT)
+ .clickable(onClick = onToggle)
+ .semantics { contentDescription = label },
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ Modifier
+ .size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
+ .background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
+ )
}
}
@@ -913,6 +1118,14 @@ private val SPLIT_DOT_SIZE = 5.dp
*/
private const val SPLIT_GRID_ROWS = 6
+/**
+ * The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
+ * comfortable tap target on its own.
+ */
+private val SPLIT_HANDLE_WIDTH = 32.dp
+private val SPLIT_HANDLE_HEIGHT = 4.dp
+private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
+
/**
* The split style's grid (#53): the month compressed to day numbers and event
* dots, with the selected day listed underneath by [SplitDayPane].
@@ -1290,6 +1503,8 @@ private fun MonthWeekRow(
modifier: Modifier = Modifier,
blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false,
+ /** See [MonthGrid]'s `selected`; null for every style but expanded split. */
+ selected: LocalDate? = null,
) {
val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -1334,6 +1549,21 @@ private fun MonthWeekRow(
else -> MaterialTheme.colorScheme.surfaceContainerLow
},
shape = CELL_SHAPE,
+ )
+ // Scoped to the row's own month for the same reason the
+ // compact grid scopes it: a boundary week shows the
+ // neighbour month's dates too, and the marker belongs to
+ // exactly one of them.
+ .then(
+ if (d == selected && inMonth(d)) {
+ Modifier.border(
+ width = 1.5.dp,
+ color = MaterialTheme.colorScheme.primary,
+ shape = CELL_SHAPE,
+ )
+ } else {
+ Modifier
+ },
),
)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index bdd8be4..79c5155 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -385,6 +385,8 @@
Split
A compact grid with dots for events, and the day you tap listed underneath.
Nothing scheduled
+ Show the whole month
+ Show the day\'s events
Quick-switch button
Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.
Navigation menu