@@ -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