From f9738b0c23897ae123c322b8f42a0379a38f78d4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:13:47 +0200 Subject: [PATCH] Put the day view on the paged timeline (#184) --- .../calendula/ui/CalendarHost.kt | 2 +- .../calendula/ui/day/DayPaging.kt | 22 - .../calendula/ui/day/DayScreen.kt | 1038 ----------------- .../calendula/ui/day/DayUiState.kt | 25 - .../calendula/ui/day/DayViewModel.kt | 136 --- .../calendula/ui/day/DayViewPreview.kt | 63 - .../calendula/ui/onboarding/ViewStep.kt | 3 +- .../calendula/ui/week/WeekPaging.kt | 20 + .../calendula/ui/week/WeekScreen.kt | 114 +- .../calendula/ui/week/WeekViewModel.kt | 15 + .../calendula/ui/week/WeekViewPreview.kt | 1 + .../calendula/ui/day/DayPagingTest.kt | 22 - .../calendula/ui/week/WeekPagingTest.kt | 14 + 13 files changed, 150 insertions(+), 1325 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt 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 c0b4b43..345c75a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -50,7 +50,7 @@ import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.viewBaseStack -import de.jeanlucmakiola.calendula.ui.day.DayScreen +import de.jeanlucmakiola.calendula.ui.week.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.ImportSource diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt deleted file mode 100644 index b932d7d..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.LocalDate -import kotlinx.datetime.daysUntil -import kotlinx.datetime.plus - -/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */ -internal const val DAY_PAGE_COUNT: Int = 140_000 - -private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2 - -/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */ -private val PAGE_EPOCH = LocalDate(2000, 1, 1) - -/** The day shown on pager [page]. */ -internal fun dayForPage(page: Int): LocalDate = - PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY) - -/** The pager page showing [date]. */ -internal fun dayPageFor(date: LocalDate): Int = - (EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt deleted file mode 100644 index 89b0595..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ /dev/null @@ -1,1038 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerDefaults -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateMapOf -import de.jeanlucmakiola.calendula.ui.common.jumpToPage -import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds -import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey -import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage -import de.jeanlucmakiola.calendula.ui.common.morphEvent -import de.jeanlucmakiola.calendula.ui.common.morphPageActive -import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState -import de.jeanlucmakiola.calendula.ui.common.pagedHeight -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec -import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.drop -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection -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.ui.common.LocalChipGround -import de.jeanlucmakiola.calendula.ui.common.eventPaint -import de.jeanlucmakiola.calendula.ui.common.eventSurface -import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction -import de.jeanlucmakiola.calendula.ui.common.CalendarFailure -import de.jeanlucmakiola.calendula.ui.common.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET -import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING -import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel -import de.jeanlucmakiola.calendula.ui.common.BlockTitle -import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics -import de.jeanlucmakiola.calendula.ui.common.blockTimeLines -import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement -import de.jeanlucmakiola.calendula.ui.common.ghostAlpha -import de.jeanlucmakiola.calendula.ui.common.LocalEventMove -import de.jeanlucmakiola.calendula.ui.common.MoveRequest -import de.jeanlucmakiola.calendula.ui.common.MoveTarget -import de.jeanlucmakiola.calendula.ui.common.NowLine -import de.jeanlucmakiola.calendula.ui.common.TimelineDragController -import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay -import de.jeanlucmakiola.calendula.ui.common.TimelineDrop -import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes -import de.jeanlucmakiola.calendula.ui.common.continuesAfter -import de.jeanlucmakiola.calendula.ui.common.continuesBefore -import de.jeanlucmakiola.calendula.ui.common.ChipCuts -import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts -import de.jeanlucmakiola.calendula.ui.common.timedBlockShape -import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed -import de.jeanlucmakiola.calendula.ui.common.eventMoveAction -import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow -import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource -import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController -import de.jeanlucmakiola.calendula.ui.common.startInstant -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec -import de.jeanlucmakiola.floret.identity.rememberReduceMotion -import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.calendula.ui.common.EventChipShape -import de.jeanlucmakiola.calendula.ui.common.trimmedLines -import de.jeanlucmakiola.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid -import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom -import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION -import de.jeanlucmakiola.calendula.ui.common.hourHeight -import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom -import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay -import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH -import de.jeanlucmakiola.calendula.ui.common.HourGutter -import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET -import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset -import de.jeanlucmakiola.calendula.ui.common.hourGridCells -import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay -import de.jeanlucmakiola.calendula.ui.week.TimedBlock -import de.jeanlucmakiola.calendula.ui.week.weekDayHeaderHeight -import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import java.util.Locale -import kotlin.math.roundToInt - -/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ -private val ALL_DAY_ROW_HEIGHT = 20.dp -private val ALL_DAY_VERTICAL_PADDING = 6.dp - -/** Breathing room between the all-day strip and the scrolling timeline below. */ -private val TIMELINE_TOP_GAP = 8.dp - -/** Total all-day strip height for the day (0 when there are no all-day events). */ -internal fun DayUiState.Success.allDayStripHeight(): Dp { - if (allDay.isEmpty()) return 0.dp - val lanes = allDay.maxOf { it.lane } + 1 - return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DayScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, - onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, - onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, - modifier: Modifier = Modifier, - initialDateIso: String? = null, - viewModel: DayViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle() - - // When opened from the month grid, anchor to the tapped date. - LaunchedEffect(initialDateIso) { - initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) } - } - - val drawerState = rememberDrawerState(DrawerValue.Closed) - val scope = rememberCoroutineScope() - - // Opens on the tapped date directly, rather than on today and then correcting. - val pagerState = rememberPagerState( - initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage, - ) { DAY_PAGE_COUNT } - val pageSpec = rememberCalendarPageSpec() - val reduceMotion = rememberReduceMotion() - // The pager leads and the anchor follows once it settles, so the anchor only - // moves on its own to correct the pager — e.g. re-entry from the month grid - // on another date. Snapped, since that is a correction rather than a move. - LaunchedEffect(anchorPage) { - if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) { - pagerState.scrollToPage(anchorPage) - } - } - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled) - } - // The day under the finger, so the title turns over mid-swipe, not after it. - val date = dayForPage(pagerState.currentPage) - - // Off the ticking clock rather than the loaded day, so it turns over at - // midnight; derived, so the per-minute tick doesn't recompose the screen. - val nowState = rememberCurrentMinute() - val zone = remember { TimeZone.currentSystemDefault() } - val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } } - val isOnToday = state !is DayUiState.Success || date == today - - // Straight to the pager: a tap mid-fling must still land, and the anchor - // hasn't caught up with the fling yet to tell the difference. - val jumpToDate: (LocalDate) -> Unit = { target -> - scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) } - } - val jumpToToday = { jumpToDate(today) } - - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the day swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = date, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - DayTopBar( - date = date, - currentYear = today.year, - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - }, - floatingActionButton = { - CalendarFabColumn( - todayVisible = !isOnToday && !todayInToolbar, - todayText = stringResource(R.string.day_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(date, null) }, - ) - }, - ) { innerPadding -> - DayContent( - state = state, - pagerState = pagerState, - day = viewModel::day, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - } - } -} - -@Composable -private fun DayContent( - state: DayUiState, - pagerState: PagerState, - day: (LocalDate) -> StateFlow, - onRetry: () -> Unit, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - modifier: Modifier = Modifier, -) { - val fadeSpec = rememberCalendarFadeSpec() - - // Shared by every page and the gutter, so the vertical scroll position - // survives day-to-day swipes, and carried over from the timeline view - // before this one. - val stripHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp - val scrollState = rememberTimelineScrollState(stripHeight + TIMELINE_TOP_GAP) - - // Above the pager: a page change mid-drag would strand the floating block - // inside the outgoing page. - val dragController = rememberTimelineDragController() - val move = LocalEventMove.current - val zone = remember { TimeZone.currentSystemDefault() } - val onDrop: (TimelineDrop) -> Unit = { drop -> - move?.move( - MoveRequest( - eventId = drop.event.eventId, - beginMillis = drop.event.start.toEpochMilliseconds(), - endMillis = drop.event.end.toEpochMilliseconds(), - target = MoveTarget.Start(drop.startInstant(zone)), - ), - ) - } - - Box(modifier = modifier) { - // Loading and failure concern the provider as a whole, so they stand in - // for the pager; a page still loading its own day handles that itself. - AnimatedContent( - targetState = state, - modifier = Modifier.fillMaxSize(), - contentKey = { it::class }, - transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, - label = "day-state", - ) { s -> - when (s) { - DayUiState.Loading -> DayLoading() - is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is DayUiState.Success -> DayPager( - pagerState = pagerState, - day = day, - today = s.today, - initialStripHeight = s.allDayStripHeight(), - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } - } - TimelineDragOverlay(dragController) - } -} - -/** - * The days as pages that follow the finger and snap once a swipe passes half a - * page or is flung (#336), beside a gutter that stays put. - */ -@Composable -private fun DayPager( - pagerState: PagerState, - day: (LocalDate) -> StateFlow, - today: LocalDate, - initialStripHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - val stripHeights = remember { mutableStateMapOf() } - val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) - DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - // The days either side are composed ahead, so a swipe lands on a - // loaded day rather than watching it fill in. - beyondViewportPageCount = 1, - // A held block moves within its day; the page stays put under it. - userScrollEnabled = !dragController.isDragging, - flingBehavior = PagerDefaults.flingBehavior( - state = pagerState, - snapAnimationSpec = rememberCalendarPageSpec(), - ), - ) { page -> - val date = dayForPage(page) - val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle() - val loaded = pageState as? DayUiState.Success - // Until its day arrives a page shows the empty column. - val empty = remember(date, today) { - DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList()) - } - val stripHeight = loaded?.allDayStripHeight() - DisposableEffect(page, stripHeight) { - if (stripHeight != null) stripHeights[page] = stripHeight - onDispose { stripHeights.remove(page) } - } - ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { - DayPage( - state = loaded ?: empty, - allDayHeight = allDayHeight, - scrollState = scrollState, - dragController = dragController, - active = page == pagerState.currentPage, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } - } - } -} - -/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */ -@Composable -private fun DayFrame( - allDayHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - pages: @Composable () -> Unit, -) { - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { - Spacer( - Modifier - .fillMaxWidth() - .height(allDayHeight) - .background(MaterialTheme.colorScheme.surface), - ) - Spacer(Modifier.height(TIMELINE_TOP_GAP)) - // Resolves the hour height off the same viewport height the pages' - // timelines have, so labels and column agree. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val zoom = LocalTimelineZoom.current - val fitHeight = maxHeight + allDayHeight - weekDayHeaderHeight() - val hourHeight = zoom.scale.hourHeight(fitHeight) - // Start inset so the labels centre on the top bar hamburger, - // matching the week view. - HourGutter( - scrollState = scrollState, - hourHeight = hourHeight, - dragController = dragController, - modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom), - ) - } - } - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() } - } -} - -/** A single day in its frame, without the pager — for the Settings preview. */ -@Composable -internal fun DaySuccess( - state: DayUiState.Success, - scrollState: ScrollState, - allDayHeight: Dp, - dragController: TimelineDragController, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { - DayPage( - state = state, - allDayHeight = allDayHeight, - scrollState = scrollState, - dragController = dragController, - active = true, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } -} - -/** - * One day's page: all-day strip and column. - * - * @param active whether this is the page on screen, the only one that may - * publish the drag geometry. - */ -@Composable -private fun DayPage( - state: DayUiState.Success, - allDayHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - active: Boolean, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - Column(modifier = Modifier.fillMaxSize()) { - // All-day strip collapses to nothing when the day has no all-day events, - // so the timeline sits directly under the app bar. - // This strip is painted on `surface`, so an outlined chip has to fill - // with that and not with the column's container (#230). - CompositionLocalProvider( - LocalChipGround provides MaterialTheme.colorScheme.surface, - ) { - AllDayStrip( - state = state, - height = allDayHeight, - onEventClick = onEventClick, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface), - ) - } - Spacer(Modifier.height(TIMELINE_TOP_GAP)) - Timeline( - state = state, - allDayHeight = allDayHeight, - scrollState = scrollState, - dragController = dragController, - active = active, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DayTopBar( - date: LocalDate, - currentYear: Int, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - val locale = currentLocale() - val (title, shortTitle) = remember(date, locale, currentYear) { - formatDayTitle(date, locale, currentYear) to - formatDayTitle(date, locale, currentYear, abbreviated = true) - } - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = date, - onJumpToDate = onJumpToDate, - shortTitle = shortTitle, - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} - -@Composable -private fun AllDayStrip( - state: DayUiState.Success, - height: Dp, - onEventClick: (EventInstance) -> Unit, - modifier: Modifier = Modifier, -) { - val dark = isSystemInDarkTheme() - - Row( - modifier = modifier - // Height is hoisted + animated so it resizes smoothly; padding sits - // inside it so the content area is lanes * row height. - .height(height) - .padding( - top = ALL_DAY_VERTICAL_PADDING, - bottom = ALL_DAY_VERTICAL_PADDING, - end = TIMELINE_CONTENT_END_INSET, - ), - ) { - // Bars are positioned absolutely by lane (vertical stacking); each spans - // the full day-column width. clipToBounds keeps bars from spilling out - // while the height animates. - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clipToBounds(), - ) { - val barWidth = maxWidth - state.allDay.forEach { span -> - AllDayBar( - event = span.event, - dark = dark, - onClick = { onEventClick(span.event) }, - modifier = Modifier - .offset(y = ALL_DAY_ROW_HEIGHT * span.lane) - .morphEvent(ViewMorphKey.event(span.event, state.date)) - .width(barWidth) - .height(ALL_DAY_ROW_HEIGHT) - .padding(horizontal = 1.dp, vertical = 1.dp), - ) - } - } - } -} - -@Composable -private fun AllDayBar( - event: EventInstance, - dark: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val title = event.title.ifBlank { stringResource(R.string.event_untitled) } - val paint = eventPaint(event, dark) - Box( - modifier = modifier - .eventSurface(paint, EventChipShape) - .clickable(onClick = onClick) - .padding(horizontal = 6.dp, vertical = 2.dp) - .semantics { contentDescription = title }, - contentAlignment = Alignment.CenterStart, - ) { - val titleOverflow = eventTitleOverflow() - Text( - text = title, - style = MaterialTheme.typography.labelSmall.trimmedLines(), - maxLines = 1, - overflow = titleOverflow.overflow, - softWrap = titleOverflow.softWrap, - color = paint.titleInk, - fontWeight = paint.titleWeight, - textDecoration = paint.decoration, - ) - } -} - -@Composable -private fun Timeline( - state: DayUiState.Success, - allDayHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - active: Boolean, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - val dark = isSystemInDarkTheme() - val zoom = LocalTimelineZoom.current - val density = LocalDensity.current - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - // What the hour grid takes off a block's ends, for the floating copy to - // take off its own. Per-end, a cut edge keeps it — that much the drag - // geometry decides itself, from the slice it is drawing. - val blockInsetPx = with(density) { - hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx() - } - - // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the - // timeline's own viewport height, which is only known here — below the top - // bar and all-day strip. It fits the week view's timeline instead, so an - // hour is as tall here as there (#184). - val weekHeader = weekDayHeaderHeight() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val fitHeight = maxHeight + allDayHeight - weekHeader - val hourHeight = zoom.scale.hourHeight(fitHeight) - val totalHeight = hourHeight * 24 - // The pinch sits above the scroll viewport: it has to outrank the - // vertical scroll, and it does that by watching the initial pass, which - // only reaches it if it is the viewport's ancestor. - val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom) - TimelineGrounds( - days = listOf(state.date), - scrollState = scrollState, - hourHeight = hourHeight, - columnGap = 0.dp, - modifier = Modifier - .fillMaxSize() - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)), - ) - // Scrolls on the same state as the gutter and the other pages, so they - // all stay aligned. A static, rounded-clipped window — the content - // scrolls inside it, so the soft corners are permanent at any scroll - // position. - Box( - modifier = Modifier - .fillMaxSize() - .then(pinch) - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState) - .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, - ) { - DayColumnCard( - blocks = state.timed, - dark = dark, - date = state.date, - today = state.today, - hourHeight = hourHeight, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - modifier = Modifier - .fillMaxWidth() - .height(totalHeight) - // The scrolling content itself, so its root position - // already folds in the scroll offset. Only the page on - // screen publishes it: the ones either side are laid out too. - .onGloballyPositioned { coords -> - if (!active) return@onGloballyPositioned - dragController.geometry.let { - it.grid = coords - it.scroll = scrollState - it.hourPx = with(density) { hourHeight.toPx() } - it.blockInsetPx = blockInsetPx - it.columnGapPx = 0f - it.columnWidthPx = coords.size.width.toFloat() - it.days = listOf(state.date) - it.isRtl = isRtl - } - }, - ) - } - } -} - -@Composable -private fun DayColumnCard( - blocks: List, - dark: Boolean, - date: LocalDate, - today: LocalDate, - hourHeight: Dp, - dragController: TimelineDragController, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, - modifier: Modifier = Modifier, -) { - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - val showHourGrid = LocalShowHourGrid.current - val zone = remember { TimeZone.currentSystemDefault() } - // Tells a settled drop when this column has caught up with it. - LaunchedEffect(blocks, dragController.settling) { - dragController.noteGrid(date, blocks) - } - // The tap detector outlives the composition that installed it — a pinch or a - // Settings change moves the hour height without restarting it — so it reads - // the height and the callback through state handles instead of capturing - // them, or taps land on the scale the column had before the zoom (#148). - val currentHourPx = rememberUpdatedState(hourPx) - val currentOnCreateAt = rememberUpdatedState(onCreateAt) - Card( - // Plain rectangular column — the soft corners come from the outer - // rounded scroll viewport, so inner rounding would look odd at the edges. - shape = RectangleShape, - colors = CardDefaults.cardColors( - // With the grid on, the container colour moves onto the hour cells - // and the column behind them recedes to surface, so an hour boundary - // reads as negative space between two surfaces. - containerColor = if (showHourGrid) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - modifier = modifier, - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - // The hour cells sit over the column background but under the - // event blocks (drawBehind paints before the children). - .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer) - // Tap an empty slot to create an event there. Taps on event - // blocks are consumed by their own click handler first, so this - // only fires on the column background. Snaps to the tapped hour. - .pointerInput(date) { - detectTapGestures { offset -> - currentOnCreateAt.value( - date, - tappedMinuteOfDay(offset.y, currentHourPx.value), - ) - } - }, - ) { - val colWidth = maxWidth - val minEventHeight = hourHeight * MIN_EVENT_FRACTION - // Keyed by event, so a block that changes time or lane is the *same* - // composable afterwards and tweens there. The ordinal disambiguates - // a column holding two occurrences of one series. - val ordinals = mutableMapOf() - blocks.forEach { block -> - val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 - key(block.event.eventId, ordinal) { - val laneWidth = colWidth / block.laneCount - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight - val place = animatedBlockPlacement( - x = laneWidth * block.lane, - y = top, - width = laneWidth, - height = height, - ) - // Where this block is cut at midnight, which decides both - // its shape and the half-gap it gives up to the hour cells. - val cuts = remember(block, date, zone) { - timedBlockCuts( - block.continuesBefore(date, zone), - block.continuesAfter(date, zone), - ) - } - val topInset = hourCellBlockInset(showHourGrid, cuts.top) - val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) - val blockHeight = (place.height - topInset - bottomInset) - .coerceAtLeast(0.dp) - EventBlock( - block = block, - dark = dark, - height = blockHeight, - width = place.width, - date = date, - cuts = cuts, - topInset = topInset, - dragController = dragController, - onClick = { onEventClick(block.event) }, - onDrop = onDrop, - modifier = Modifier - .offset(x = place.x, y = place.y + topInset) - .morphEvent(ViewMorphKey.event(block.event, date)) - .width(place.width) - .height(blockHeight) - .padding(horizontal = BLOCK_OUTER_INSET), - ) - } - } - // Current-time line, on top of the events, only on today's column. - if (date == today) { - NowLine(date = date, hourHeight = hourHeight) - } - } - } -} - -@Composable -private fun EventBlock( - block: TimedBlock, - dark: Boolean, - height: Dp, - width: Dp, - date: LocalDate, - cuts: ChipCuts, - topInset: Dp, - dragController: TimelineDragController, - onClick: () -> Unit, - onDrop: (TimelineDrop) -> Unit, - modifier: Modifier = Modifier, -) { - val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } - val use24Hour = LocalUse24HourFormat.current - val locale = currentLocale() - val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + - minToHm(block.endMin, use24Hour, locale) - val density = LocalDensity.current - val metrics = rememberBlockTextMetrics(height) - // A block that cannot afford both lines spends its space on the title, and - // one too short even for that drops the title rather than serving a sliced one. - // Height alone decides: a duration threshold would keep hiding the time on a - // half-hour block the user has pinched open to three times the room it needs. - val showTime = metrics.available >= metrics.titleLine + metrics.timeLine - val showTitle = metrics.fitsTitle - val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 - // Only lines the block can actually draw: a block too short for the time is - // too short for a second title line too, and asking for one served a sliced - // one — as well as handing the drag copy a count it couldn't honour, so the - // title re-wrapped the moment the block was lifted (#267). - val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1) - val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2) - // On a day column — wide enough for "09:30–11:00" several times over — the - // range never needs the second line, until lanes cut the column down. - val spare = metrics.available - metrics.titleHeight(titleMaxLines) - - if (showTime) metrics.timeLine else 0.dp - val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1 - val paint = eventPaint(block.event, dark) - val zone = remember { TimeZone.currentSystemDefault() } - val moveAction = eventMoveAction(block.event) - val draggable = eventDragAllowed(block.event) - // The drop takes this offset back off, so a tail clipped at midnight lands - // where the event's own start belongs (#253). - val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } - val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } - val topInsetPx = with(density) { topInset.toPx() } - val dragModifier = rememberEventDragSource( - enabled = draggable, - key = block.event.instanceId, - onPickUp = { pointer, blockRoot, _ -> - dragController.begin( - block = block, - clipOffsetMin = clipOffset, - titleLines = if (showTitle) titleMaxLines else 0, - pointerInRoot = pointer, - // Measured off the block's placement rather than off where the - // hour grid seats it: the copy is drawn from that placement and - // re-seated the same half-gap down (#267). - blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx), - ) - }, - onMove = dragController::move, - onDrop = { dragController.finish()?.let(onDrop) }, - onCancel = dragController::cancel, - ) - val lifted = draggable && dragController.ghosts(block) - val ghost = ghostAlpha(lifted) - Box( - modifier = modifier - // The source stays put as a ghost while its floating copy travels. - .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .eventSurface(paint, shape, cuts) - .clickable(onClick = onClick) - // After clickable, so it is the inner node and wins the main pass; - // the tap still works, since a drag consumes the up. - .then(dragModifier) - .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset) - .semantics { - contentDescription = "$title, $timeLabel" - if (moveAction != null) customActions = listOf(moveAction) - }, - ) { - Column { - if (showTitle) { - BlockTitle( - title = title, - maxLines = titleMaxLines, - color = paint.titleInk, - textDecoration = paint.decoration, - fontWeight = paint.titleWeight, - ) - } - if (showTime) { - BlockTimeLabel( - label = timeLabel, - color = paint.secondaryInk, - maxLines = timeMaxLines, - ) - } - } - } -} - -@Composable -private fun DayLoading() { - val scale = LocalTimelineZoom.current.scale - val scrollState = rememberScrollState() - val weekHeader = weekDayHeaderHeight() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - // Same scale resolution as the loaded timeline, so the skeleton's column - // doesn't resize the moment the real day arrives. - val hourHeight = scale.hourHeight(maxHeight - weekHeader - TIMELINE_TOP_GAP) - val totalHeight = hourHeight * 24 - // The skeleton wears the loaded column's own ground, so the arrival of - // the real day doesn't flash a solid block into a gapped grid. - val showHourGrid = LocalShowHourGrid.current - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - Row( - modifier = Modifier - .fillMaxSize() - .padding(end = TIMELINE_CONTENT_END_INSET) - .verticalScroll(scrollState), - ) { - Spacer(Modifier.width(GUTTER_WIDTH)) - Box( - modifier = Modifier - .weight(1f) - .height(totalHeight) - .padding(horizontal = 2.dp) - .background( - if (showHourGrid) MaterialTheme.colorScheme.surface - else MaterialTheme.colorScheme.surfaceContainer, - ) - .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer), - ) - } - } -} - -private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = - formatMinuteOfDay(min, is24Hour, locale) - -/** [abbreviated] drops the weekday, leaving the date itself (#165). */ -private fun formatDayTitle( - date: LocalDate, - locale: Locale, - currentYear: Int, - abbreviated: Boolean = false, -): String = - formatCalendarTitle( - date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), - locale = locale, - currentYear = currentYear, - skeleton = if (abbreviated) "dMMM" else "EEEdMMM", - ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt deleted file mode 100644 index 2883366..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt +++ /dev/null @@ -1,25 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import de.jeanlucmakiola.calendula.domain.FailureReason -import de.jeanlucmakiola.calendula.ui.week.AllDaySpan -import de.jeanlucmakiola.calendula.ui.week.TimedBlock -import kotlinx.datetime.LocalDate - -/** - * The day view is a single-column slice of the week view (spec S3). It reuses the - * week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day - * spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol] - * == 0) and only their [AllDaySpan.lane] (vertical stacking) matters. - */ -sealed interface DayUiState { - data object Loading : DayUiState - data class Failure(val reason: FailureReason) : DayUiState - data class Success( - val date: LocalDate, - val today: LocalDate, - /** All-day/multi-day events covering this day, stacked by lane. */ - val allDay: List, - /** Timed events clipped to this day with overlap lanes resolved. */ - val timed: List, - ) : DayUiState -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt deleted file mode 100644 index e310d49..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt +++ /dev/null @@ -1,136 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository -import de.jeanlucmakiola.calendula.data.di.IoDispatcher -import de.jeanlucmakiola.calendula.domain.CalendarSource -import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.FailureReason -import de.jeanlucmakiola.calendula.domain.calendarListFailure -import de.jeanlucmakiola.calendula.ui.common.PageStateCache -import de.jeanlucmakiola.calendula.ui.week.layoutAllDay -import de.jeanlucmakiola.calendula.ui.week.layoutDay -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.atTime -import kotlinx.datetime.daysUntil -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock -import kotlin.time.Instant -import javax.inject.Inject -import kotlin.math.abs - -/** How far from the requested day [DayViewModel.day] keeps other days cached. */ -private const val DAY_CACHE_DAYS = 7 - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class DayViewModel @Inject constructor( - private val repository: CalendarRepository, - @IoDispatcher private val io: CoroutineDispatcher, -) : ViewModel() { - - private val zone = TimeZone.currentSystemDefault() - - private val todayDate: LocalDate - get() = Clock.System.now().toLocalDateTime(zone).date - - private val _date = MutableStateFlow(todayDate) - - /** The pager page the anchor day sits on. */ - val anchorPage: StateFlow = _date - .map { dayPageFor(it) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = dayPageFor(todayDate), - ) - - /** - * The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]: - * moving the anchor keeps the last result until the new day arrives, since - * the pages draw their own days and this only gates the failure screen. - */ - val state: StateFlow = _date - .flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } } - .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = DayUiState.Loading, - ) - - private val days = PageStateCache( - scope = viewModelScope, - initial = DayUiState.Loading, - // Far enough to cover the pages either side and a swipe back, so paging - // through a month doesn't keep a month of queries around. - keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS }, - ) { date -> - combine( - repository.calendars(), - repository.instances(dayRange(date, zone)), - ) { calendars, instances -> - buildState(date, calendars, instances) - } - .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) - } - - /** The day [date], shared between its pager page and [state]. Main thread only. */ - fun day(date: LocalDate): StateFlow = days.get(date) - - /** The pager came to rest on [page]; follow it unless it is already the anchor's. */ - fun onPageSettled(page: Int) { - if (dayPageFor(_date.value) != page) _date.value = dayForPage(page) - } - - fun goToToday() { - _date.value = todayDate - } - - /** Jump to a specific date (e.g. when opened from the month grid). */ - fun goToDate(date: LocalDate) { - _date.value = date - } - - private fun buildState( - day: LocalDate, - calendars: List, - instances: List, - ): DayUiState { - calendarListFailure(calendars)?.let { return DayUiState.Failure(it) } - val days = listOf(day) - val allDay = instances.filter { it.isAllDay } - val timed = instances.filterNot { it.isAllDay } - return DayUiState.Success( - date = day, - today = todayDate, - allDay = layoutAllDay(allDay, days, zone), - timed = layoutDay(timed, day, zone), - ) - } -} - -/** Half-open instant range covering the single calendar [date]. */ -internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange { - val from = date.atStartOfDayIn(zone) - val to = date.atTime(23, 59, 59).toInstant(zone) - return from..to -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt deleted file mode 100644 index 90303d9..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt +++ /dev/null @@ -1,63 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview -import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController -import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents -import de.jeanlucmakiola.calendula.ui.week.layoutAllDay -import de.jeanlucmakiola.calendula.ui.week.layoutDay -import kotlinx.coroutines.flow.first -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock - -/** - * A live, scaled-down Day view for the onboarding view chooser. Today's date is - * real; only the events are stand-ins. - */ -@Composable -internal fun DayViewPreview( - height: Dp, - modifier: Modifier = Modifier, -) { - val zone = remember { TimeZone.currentSystemDefault() } - val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } - val state = remember(today, zone) { sampleDayState(today, zone) } - val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // Half the scroll range is noon — the same centring the live view does. - scrollState.scrollTo(scrollState.maxValue / 2) - } - - ScaledViewPreview(height = height, modifier = modifier) { - DaySuccess( - state = state, - scrollState = scrollState, - allDayHeight = state.allDayStripHeight(), - dragController = rememberTimelineDragController(), - onEventClick = {}, - onCreateAt = { _, _ -> }, - onDrop = {}, - ) - } -} - -/** Sample day state, laid out through the same helpers the live view uses. */ -private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success { - val days = listOf(today) - val events = sampleTimelineEvents(days, today, zone) - return DayUiState.Success( - date = today, - today = today, - allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone), - timed = layoutDay(events, today, zone), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index 8f71abc..defdd8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -23,7 +23,6 @@ import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.label -import de.jeanlucmakiola.calendula.ui.day.DayViewPreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.descriptionRes @@ -73,7 +72,7 @@ internal fun ViewStep( height = PREVIEW_HEIGHT, dayCount = LocalMultiDayCount.current, ) - CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT) + CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1) CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index 5c2d92d..e9f43f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -73,3 +73,23 @@ data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid { DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount)) } } + +/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */ +internal const val DAY_PAGE_COUNT: Int = 140_000 + +private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2 + +/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */ +private val DAY_EPOCH = LocalDate(2000, 1, 1) + +/** Single days: every date starts a page of its own. */ +data object DayGrid : PageGrid { + override val dayCount: Int get() = 1 + override val pageCount: Int get() = DAY_PAGE_COUNT + + override fun startForPage(page: Int): LocalDate = + DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY) + + override fun pageFor(date: LocalDate): Int = + (DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 13f9450..4e67dd3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip @@ -184,6 +185,9 @@ private val HEADER_BOTTOM_PADDING = 8.dp /** The header's date slot, reserved whether or not it holds today's circle. */ private val DATE_SLOT_SIZE = 28.dp +/** A start-aligned header cell's inset, lining its date up with the event titles below. */ +private val HEADER_START_INSET = BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING + /** The weekday header's height, which the day view takes off to fit the week's hours (#184). */ @Composable internal fun weekDayHeaderHeight(): Dp { @@ -216,7 +220,7 @@ fun WeekScreen( viewModel: WeekViewModel = hiltViewModel(), ) = PagedTimelineScreen( viewModel = viewModel, - showWeekNumber = true, + kind = TimelineKind.Week, selectedView = selectedView, onSelectView = onSelectView, onOpenDay = onOpenDay, @@ -247,8 +251,7 @@ fun MultiDayScreen( viewModel: MultiDayViewModel = hiltViewModel(), ) = PagedTimelineScreen( viewModel = viewModel, - // A run of days often straddles two weeks, so no single number fits it. - showWeekNumber = false, + kind = TimelineKind.MultiDay, selectedView = selectedView, onSelectView = onSelectView, onOpenDay = onOpenDay, @@ -262,11 +265,66 @@ fun MultiDayScreen( modifier = modifier, ) +/** The day view: the week view's timeline one day at a time. */ +@Composable +fun DayScreen( + selectedView: CalendarView, + onSelectView: (CalendarView) -> Unit, + onEventClick: (EventInstance) -> Unit, + onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, + onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, + todayInToolbar: Boolean = false, + modifier: Modifier = Modifier, + initialDateIso: String? = null, + viewModel: DayViewModel = hiltViewModel(), +) = PagedTimelineScreen( + viewModel = viewModel, + kind = TimelineKind.Day, + selectedView = selectedView, + onSelectView = onSelectView, + // Tapping the one column's header would only reopen this day. + onOpenDay = {}, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, + todayInToolbar = todayInToolbar, + modifier = modifier, + initialDate = initialDateIso?.let(LocalDate::parse), +) + +/** What sets the timeline views apart; everything else they share. */ +internal enum class TimelineKind( + val showWeekNumber: Boolean, + val todayText: Int, + /** Where a header cell sits in its column: centred, or at the start like the event titles. */ + val headerBias: Float, +) { + Week(showWeekNumber = true, todayText = R.string.week_today_action, headerBias = 0f), + + // A run of days often straddles two weeks, so no single number fits it. + MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f), + Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f), + ; + + fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String = + if (this == Day) { + formatDayTitle(start, locale, currentYear, abbreviated) + } else { + formatWeekTitle(start, end, locale, currentYear, abbreviated) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PagedTimelineScreen( viewModel: PagedTimelineViewModel, - showWeekNumber: Boolean, + kind: TimelineKind, selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -278,7 +336,9 @@ private fun PagedTimelineScreen( drawerViewOrder: List, todayInToolbar: Boolean, modifier: Modifier, + initialDate: LocalDate? = null, ) { + LaunchedEffect(initialDate) { initialDate?.let(viewModel::jumpTo) } val state by viewModel.state.collectAsStateWithLifecycle() val position by viewModel.position.collectAsStateWithLifecycle() val grid = position.grid @@ -368,6 +428,7 @@ private fun PagedTimelineScreen( modifier = modifier, topBar = { WeekTopBar( + kind = kind, weekStart = weekStart, weekEnd = weekEnd, currentYear = today.year, @@ -384,7 +445,7 @@ private fun PagedTimelineScreen( floatingActionButton = { CalendarFabColumn( todayVisible = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(R.string.week_today_action), + todayText = stringResource(kind.todayText), onToday = jumpToToday, onCreate = { // Anchor on today when it's in view, else the week's first day. @@ -398,7 +459,7 @@ private fun PagedTimelineScreen( state = state, pagerState = pagerState, grid = grid, - showWeekNumber = showWeekNumber, + kind = kind, span = viewModel::span, onRetry = viewModel::goToToday, onEventClick = onEventClick, @@ -418,7 +479,7 @@ private fun WeekContent( state: WeekUiState, pagerState: PagerState, grid: PageGrid, - showWeekNumber: Boolean, + kind: TimelineKind, span: (DaySpan) -> StateFlow, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, @@ -466,7 +527,7 @@ private fun WeekContent( is WeekUiState.Success -> WeekPager( pagerState = pagerState, grid = grid, - showWeekNumber = showWeekNumber, + kind = kind, span = span, today = s.today, initialStripHeight = s.allDayStripHeight(), @@ -491,7 +552,7 @@ private fun WeekContent( private fun WeekPager( pagerState: PagerState, grid: PageGrid, - showWeekNumber: Boolean, + kind: TimelineKind, span: (DaySpan) -> StateFlow, today: LocalDate, initialStripHeight: Dp, @@ -506,7 +567,7 @@ private fun WeekPager( val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) WeekFrame( weekStart = grid.startForPage(pagerState.currentPage), - showWeekNumber = showWeekNumber, + showWeekNumber = kind.showWeekNumber, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -537,6 +598,7 @@ private fun WeekPager( ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { WeekPage( state = loaded ?: empty, + headerBias = kind.headerBias, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -610,6 +672,7 @@ private fun WeekFrame( internal fun WeekSuccess( state: WeekUiState.Success, showWeekNumber: Boolean = true, + headerBias: Float = 0f, scrollState: ScrollState, allDayHeight: Dp, dragController: TimelineDragController, @@ -627,6 +690,7 @@ internal fun WeekSuccess( ) { WeekPage( state = state, + headerBias = headerBias, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -648,6 +712,7 @@ internal fun WeekSuccess( @Composable private fun WeekPage( state: WeekUiState.Success, + headerBias: Float, allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, @@ -663,7 +728,7 @@ private fun WeekPage( .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) { - WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) + WeekDayHeader(days = state.days, today = state.today, bias = headerBias, onOpenDay = onOpenDay) // This strip is painted on `surface`, so an outlined chip has to // fill with that and not with the columns' container (#230). CompositionLocalProvider( @@ -689,6 +754,7 @@ private fun WeekPage( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun WeekTopBar( + kind: TimelineKind, weekStart: LocalDate, weekEnd: LocalDate, currentYear: Int, @@ -702,9 +768,9 @@ private fun WeekTopBar( onToday: () -> Unit, ) { val locale = currentLocale() - val (title, shortTitle) = remember(weekStart, weekEnd, locale, currentYear) { - formatWeekTitle(weekStart, weekEnd, locale, currentYear) to - formatWeekTitle(weekStart, weekEnd, locale, currentYear, abbreviated = true) + val (title, shortTitle) = remember(kind, weekStart, weekEnd, locale, currentYear) { + kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = false) to + kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = true) } TopAppBar( title = { @@ -751,6 +817,7 @@ private fun WeekTopBar( private fun WeekDayHeader( days: List, today: LocalDate, + bias: Float, onOpenDay: (LocalDate) -> Unit, ) { val locale = currentLocale() @@ -766,8 +833,9 @@ private fun WeekDayHeader( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(12.dp)) - .clickable { onOpenDay(date) }, - horizontalAlignment = Alignment.CenterHorizontally, + .clickable { onOpenDay(date) } + .padding(start = HEADER_START_INSET * -bias), + horizontalAlignment = BiasAlignment.Horizontal(bias), ) { // One line, as the gutter cell beside it assumes (see WeekNumberCell). Text( @@ -1384,3 +1452,17 @@ private fun formatWeekTitle( forceYear = weekEnd.year != weekStart.year, ) } + +/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */ +private fun formatDayTitle( + date: LocalDate, + locale: Locale, + currentYear: Int, + abbreviated: Boolean = false, +): String = + formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), + locale = locale, + currentYear = currentYear, + skeleton = if (abbreviated) "dMMM" else "EEEdMMM", + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index fef5385..729db3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -208,6 +209,20 @@ class MultiDayViewModel @Inject constructor( initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) }, ) +/** The day view: the timeline one day at a time. */ +@HiltViewModel +class DayViewModel @Inject constructor( + repository: CalendarRepository, + settingsPrefs: SettingsPrefs, + @IoDispatcher io: CoroutineDispatcher, +) : PagedTimelineViewModel( + repository = repository, + settingsPrefs = settingsPrefs, + io = io, + gridRule = flowOf { _: LocalDate -> DayGrid }, + initialRule = { DayGrid }, +) + /** * Lay out all-day events as connected horizontal spans across the visible week. * Each event becomes one [AllDaySpan] from its first to its last covered column; diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index 8a7feee..d336cdf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -50,6 +50,7 @@ internal fun WeekViewPreview( WeekSuccess( state = state, showWeekNumber = dayCount == 7, + headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f, scrollState = scrollState, allDayHeight = state.allDayStripHeight(), dragController = rememberTimelineDragController(), diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt deleted file mode 100644 index ac37f9b..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import com.google.common.truth.Truth.assertThat -import kotlinx.datetime.LocalDate -import org.junit.jupiter.api.Test - -class DayPagingTest { - - @Test - fun `a page and its day round-trip`() { - listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> - assertThat(dayForPage(dayPageFor(day))).isEqualTo(day) - } - } - - @Test - fun `adjacent pages are adjacent days across a month end`() { - val page = dayPageFor(LocalDate(2026, 2, 28)) - assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) - assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) - } -} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt index 2ac6c63..0480c9f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -64,4 +64,18 @@ class WeekPagingTest { assertThat(grid.startForPage(page)).isEqualTo(date) assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13)) } + + @Test + fun `a day page and its day round-trip`() { + listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> + assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day) + } + } + + @Test + fun `adjacent day pages are adjacent days across a month end`() { + val page = DayGrid.pageFor(LocalDate(2026, 2, 28)) + assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) + assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) + } }