feat(month): split style with a day pane (#53)
Adds MonthViewStyle.Split: the month compressed to day numbers and event dots over a list of the selected day's events. - The app's first selected-day concept. Every other view drills straight into a date; here a tap selects, because the pane below is already the answer to "what's on this day" and opening a whole screen would defeat the layout. The full Day view stays one tap away on the pane's date header, matching the Week and Agenda headers (#37). - The pane reuses the agenda's row vocabulary (extracted in the groundwork commit) rather than growing a parallel set, so the two surfaces read as one app. It needs no extra provider query: instancesByDay is already covered by the month grid range. - Selection follows the month — today when the new month holds it, else the 1st — so the pane never lists a day the grid isn't showing. Tapping a leading or trailing day follows it to its own month. - Selection and today are different signals (tinted, outlined cell vs. the filled circle the other views use for today), so both read when they land on the same day. - The swipe stays on the grid alone; the pane scrolls and is full of tappable rows. MonthScreen gains onEventClick, wired in CalendarHost alongside the other three views, and MonthUiState.Success now carries its zone for the same reason the agenda's does. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -338,6 +338,7 @@ fun CalendarHost(
|
|||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -23,7 +24,9 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -31,6 +34,7 @@ import androidx.compose.material.icons.filled.Menu
|
|||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -71,7 +75,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
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.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
@@ -88,6 +96,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
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.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
|
||||||
@@ -113,6 +122,7 @@ fun MonthScreen(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
@@ -127,6 +137,7 @@ fun MonthScreen(
|
|||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle()
|
val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle()
|
||||||
|
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
|
||||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||||
// The instant before which an event counts as completed, or null when dimming
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
@@ -299,6 +310,19 @@ fun MonthScreen(
|
|||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
)
|
)
|
||||||
|
} else if (viewStyle == MonthViewStyle.Split) {
|
||||||
|
SplitMonthContent(
|
||||||
|
state = state,
|
||||||
|
selected = selectedDate,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
|
onSwipeNext = goNext,
|
||||||
|
onSwipePrev = goPrev,
|
||||||
|
onRetry = jumpToToday,
|
||||||
|
onSelectDay = viewModel::selectDate,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateEvent = { onCreateEvent(it, null) },
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -326,27 +350,10 @@ private fun MonthContent(
|
|||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
|
||||||
val threshold = with(density) { 6.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev)
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -578,6 +585,274 @@ private fun ContinuousMonthGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The month-changing horizontal swipe, shared by the paged and split styles.
|
||||||
|
* Accumulates the drag and commits past a small threshold on release — the grid
|
||||||
|
* doesn't follow the finger, so there is no distance to rubber-band against.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberMonthSwipeModifier(
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
val threshold = with(LocalDensity.current) { 6.dp.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
|
||||||
|
},
|
||||||
|
onDragCancel = { dragAccum = 0f },
|
||||||
|
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split style content: the compact grid keeps the month swipe, the pane below it
|
||||||
|
* lists whatever day is selected.
|
||||||
|
*
|
||||||
|
* No [AnimatedContent] here, unlike the paged style. The grid is 4–6 rows tall
|
||||||
|
* depending on the month, so sliding one month over another would animate a
|
||||||
|
* height change under a pane that is trying to hold still.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SplitMonthContent(
|
||||||
|
state: MonthUiState,
|
||||||
|
selected: LocalDate,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSelectDay: (LocalDate) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
MonthUiState.Loading -> MonthGridLoading()
|
||||||
|
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
|
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
|
||||||
|
SplitMonthGrid(
|
||||||
|
state = state,
|
||||||
|
selected = selected,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
|
onSelectDay = onSelectDay,
|
||||||
|
// The swipe lives on the grid alone — the pane scrolls and is
|
||||||
|
// full of tappable rows, so it shouldn't also be a month gesture.
|
||||||
|
modifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev),
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
SplitDayPane(
|
||||||
|
date = selected,
|
||||||
|
today = state.today,
|
||||||
|
events = state.instancesByDay[selected].orEmpty(),
|
||||||
|
zone = state.zone,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Split style (#53) ----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row height in the split grid. Only a day number and a row of dots to seat, so
|
||||||
|
* 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_DOT_SIZE = 5.dp
|
||||||
|
private const val SPLIT_MAX_DOTS = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The split style's grid (#53): the month compressed to day numbers and event
|
||||||
|
* dots, with the selected day listed underneath by [SplitDayPane].
|
||||||
|
*
|
||||||
|
* Tapping selects rather than drilling into the Day view — the pane is the
|
||||||
|
* answer to "what's on this day", so opening a whole screen for it would defeat
|
||||||
|
* the layout. The full Day view stays one tap away on the pane's date header.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SplitMonthGrid(
|
||||||
|
state: MonthUiState.Success,
|
||||||
|
selected: LocalDate,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
|
onSelectDay: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val month = state.month
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
state.weeks.forEach { week ->
|
||||||
|
Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) {
|
||||||
|
if (showWeekNumbers) {
|
||||||
|
WeekNumberGutter(
|
||||||
|
weekStart = week.days.first(),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
week.days.forEach { day ->
|
||||||
|
SplitDayCell(
|
||||||
|
date = day,
|
||||||
|
events = state.instancesByDay[day].orEmpty(),
|
||||||
|
isToday = day == state.today,
|
||||||
|
isSelected = day == selected,
|
||||||
|
inMonth = day.month == month.month && day.year == month.year,
|
||||||
|
dark = dark,
|
||||||
|
onClick = { onSelectDay(day) },
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One compact day: its number over up to [SPLIT_MAX_DOTS] event dots.
|
||||||
|
*
|
||||||
|
* Selection and today are deliberately different signals — a tinted, outlined
|
||||||
|
* cell versus the filled circle the other views already use for today — so the
|
||||||
|
* two read at once when they land on the same day.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SplitDayCell(
|
||||||
|
date: LocalDate,
|
||||||
|
events: List<EventInstance>,
|
||||||
|
isToday: Boolean,
|
||||||
|
isSelected: Boolean,
|
||||||
|
inMonth: Boolean,
|
||||||
|
dark: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val background = when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
inMonth -> MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.clip(CELL_SHAPE)
|
||||||
|
.background(background)
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.border(1.5.dp, MaterialTheme.colorScheme.primary, CELL_SHAPE)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.selectable(selected = isSelected, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
DayNumberCell(
|
||||||
|
date = date,
|
||||||
|
isToday = isToday,
|
||||||
|
inMonth = inMonth,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
SplitDots(events = events, dark = dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to three colour dots for a day, plus a count when more are hidden. */
|
||||||
|
@Composable
|
||||||
|
private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
|
||||||
|
if (events.isEmpty()) return
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) }
|
||||||
|
val extra = events.size - colors.size
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
colors.forEach { argb ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(SPLIT_DOT_SIZE)
|
||||||
|
.background(eventFill(argb, dark, soften), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (extra > 0) {
|
||||||
|
Text(
|
||||||
|
text = "+$extra",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* the Week and Agenda headers (#37).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SplitDayPane(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
events: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
|
||||||
|
if (events.isEmpty()) {
|
||||||
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(R.string.month_split_no_events),
|
||||||
|
onClick = { onCreateEvent(date) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = events,
|
||||||
|
key = { _, event -> event.instanceId },
|
||||||
|
) { index, event ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
day = date,
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, events.size),
|
||||||
|
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
|
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContinuousWeekPlaceholder() {
|
private fun ContinuousWeekPlaceholder() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,5 +71,12 @@ sealed interface MonthUiState {
|
|||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weeks: List<MonthWeek>,
|
val weeks: List<MonthWeek>,
|
||||||
val instancesByDay: Map<LocalDate, List<EventInstance>> = emptyMap(),
|
val instancesByDay: Map<LocalDate, List<EventInstance>> = emptyMap(),
|
||||||
|
/**
|
||||||
|
* Travels on the state rather than being read at the call site, for the
|
||||||
|
* same reason the agenda does it: a file-level constant would be fixed
|
||||||
|
* for the process lifetime and drift from the zone the events were laid
|
||||||
|
* out in after a device time-zone change.
|
||||||
|
*/
|
||||||
|
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
) : MonthUiState
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,21 +167,54 @@ 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.
|
||||||
|
|
||||||
|
private val _selectedDate = MutableStateFlow(todayDate)
|
||||||
|
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select [date], following it to its month if it sits in the grid's leading
|
||||||
|
* or trailing days — tapping a greyed-out day should show that day, not
|
||||||
|
* silently list a date the grid isn't pointing at.
|
||||||
|
*/
|
||||||
|
fun selectDate(date: LocalDate) {
|
||||||
|
_selectedDate.value = date
|
||||||
|
val target = YearMonth(date.year, date.month)
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
private fun realignSelection() {
|
||||||
|
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
||||||
|
}
|
||||||
|
|
||||||
fun goToPrev() {
|
fun goToPrev() {
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
fun goToNext() {
|
||||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
|
_selectedDate.value = todayDate
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
fun goToDate(date: LocalDate) {
|
fun goToDate(date: LocalDate) {
|
||||||
_month.value = YearMonth(date.year, date.month)
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
_selectedDate.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
@@ -199,6 +232,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
today = todayDate,
|
today = todayDate,
|
||||||
weeks = weeks,
|
weeks = weeks,
|
||||||
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,6 +344,18 @@ internal fun monthGridRange(
|
|||||||
private const val WINDOW_PAD = 12
|
private const val WINDOW_PAD = 12
|
||||||
private const val WINDOW_EDGE = 4
|
private const val WINDOW_EDGE = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||||
|
if (YearMonth(today.year, today.month) == month) {
|
||||||
|
today
|
||||||
|
} else {
|
||||||
|
LocalDate(month.year, month.month, 1)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The window to load for a visible range, or null to keep the current one.
|
* The window to load for a visible range, or null to keep the current one.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -101,4 +102,20 @@ class ContinuousWeekIndexTest {
|
|||||||
loaded = window
|
loaded = window
|
||||||
assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull()
|
assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the split selection follows the month, landing on today when it's there`() {
|
||||||
|
val today = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), today))
|
||||||
|
.isEqualTo(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the split selection falls to the 1st of any other month`() {
|
||||||
|
val today = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JULY), today))
|
||||||
|
.isEqualTo(LocalDate(2026, 7, 1))
|
||||||
|
assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today))
|
||||||
|
.isEqualTo(LocalDate(2025, 6, 1))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user