diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index cf5abea..b830f74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -338,6 +338,7 @@ fun CalendarHost( selectedView = currentView, onSelectView = onSelectView, onOpenDay = onOpenDay, + onEventClick = onEventClick, onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, 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 b5ddc8e..6a5e821 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 @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent 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.isSystemInDarkTheme @@ -23,7 +24,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -71,7 +75,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance 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.CalendarDrawer 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.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -113,6 +122,7 @@ fun MonthScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, @@ -127,6 +137,7 @@ fun MonthScreen( val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle() + val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() // The instant before which an event counts as completed, or null when dimming @@ -299,6 +310,19 @@ fun MonthScreen( onRetry = jumpToToday, 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 { MonthContent( state = state, @@ -326,27 +350,10 @@ private fun MonthContent( onRetry: () -> 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 fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() - - 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 }, - ) - } + val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) AnimatedContent( 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, + 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, 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, + 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. */ @Composable private fun ContinuousWeekPlaceholder() { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 7e1f3f0..cb59ccf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth /** @@ -70,5 +71,12 @@ sealed interface MonthUiState { val today: LocalDate, val weeks: List, val instancesByDay: Map> = 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 1de6dab..81f33ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -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 = _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() { _month.value = _month.value.minus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToNext() { _month.value = _month.value.plus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToToday() { _month.value = YearMonth(todayDate.year, todayDate.month) + _selectedDate.value = todayDate } /** Jump to the month containing [date] (drawer jump-to-date). */ fun goToDate(date: LocalDate) { _month.value = YearMonth(date.year, date.month) + _selectedDate.value = date } private fun buildState( @@ -199,6 +232,7 @@ class MonthViewModel @Inject constructor( today = todayDate, weeks = weeks, 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_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. * diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt index a45a2c3..2b17915 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth import kotlinx.datetime.plus import org.junit.jupiter.api.Test @@ -101,4 +102,20 @@ class ContinuousWeekIndexTest { loaded = window 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)) + } }