diff --git a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/component/CalendarScroller.kt b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/component/CalendarScroller.kt index 87422d8..70b44e8 100644 --- a/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/component/CalendarScroller.kt +++ b/app/src/main/java/com/lukeneedham/videodiary/ui/feature/calendar/component/CalendarScroller.kt @@ -1,11 +1,18 @@ package com.lukeneedham.videodiary.ui.feature.calendar.component +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.key +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -20,7 +27,10 @@ import com.lukeneedham.videodiary.ui.feature.calendar.component.portrait.Calenda import com.lukeneedham.videodiary.ui.feature.calendar.component.portrait.CalendarScrollerPortrait import com.lukeneedham.videodiary.ui.feature.common.videoplayer.VideoPlayerController import com.lukeneedham.videodiary.ui.feature.common.videoplayer.rememberVideoPlayerController +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +@OptIn(ExperimentalFoundationApi::class) @Composable fun CalendarScroller( days: List, @@ -34,37 +44,63 @@ fun CalendarScroller( share: (ShareRequest) -> Unit, videoPlayerController: VideoPlayerController, ) { - val currentDay = days[currentDayIndex] + // CalendarPageContent ensures days is always non-empty before reaching this composable. + require(days.isNotEmpty()) { "CalendarScroller requires a non-empty days list" } - fun goToPage(index: Int) { - if (index !in days.indices) return - setCurrentDayIndex(index) - } + val pagerState = rememberPagerState( + initialPage = currentDayIndex.coerceIn(days.indices), + pageCount = { days.size }, + ) + val coroutineScope = rememberCoroutineScope() - val currentDateFormatted = currentDay.date.format(StandardDateTimeFormatter.date) + // Notify the ViewModel when the pager settles on a new page. + // Uses snapshotFlow + distinctUntilChanged so setCurrentDayIndex is only called + // when the page actually changes, preventing spurious state updates. + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .distinctUntilChanged() + .collect { page -> setCurrentDayIndex(page) } + } - val onPrevious = { - goToPage(currentDayIndex - 1) + // Respond to external navigation (e.g. date picker) by scrolling the pager. + // The guard ensures this is a no-op when the change originated from the pager + // itself (in which case currentPage already equals currentDayIndex), breaking + // any potential cycle between the two sync effects. + LaunchedEffect(currentDayIndex) { + if (pagerState.currentPage != currentDayIndex) { + pagerState.scrollToPage(currentDayIndex) + } } - val onNext = { - goToPage(currentDayIndex + 1) + + // Pause all videos while the pager is being dragged; resume once it settles. + // Uses snapshotFlow so this only fires on scroll-state transitions, not on + // every recomposition or at initial composition. + LaunchedEffect(pagerState, videoPlayerController) { + snapshotFlow { pagerState.isScrollInProgress } + .distinctUntilChanged() + .collect { isScrolling -> + if (isScrolling) videoPlayerController.temporaryPause() + else videoPlayerController.temporaryResume() + } } - @Composable - fun DayContentFrame( - content: @Composable () -> Unit - ) { - key(currentDay) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp) - ) { - content() + // Safe fallback: pagerState may briefly lag behind after days grows, but days is + // guaranteed non-empty (enforced by the require above), so days.last() is safe. + val currentDay = days.getOrElse(pagerState.currentPage) { days.last() } + val currentDateFormatted = currentDay.date.format(StandardDateTimeFormatter.date) + + // pagerState.pageCount mirrors days.size dynamically via the pageCount lambda, so + // these callbacks only need pagerState and coroutineScope as stable remember keys. + val navigateByOffset: (Int) -> Unit = remember(pagerState, coroutineScope) { + { offset -> + val target = pagerState.currentPage + offset + if (target >= 0 && target < pagerState.pageCount) { + coroutineScope.launch { pagerState.animateScrollToPage(target) } } } } + val onPrevious: () -> Unit = remember(navigateByOffset) { { navigateByOffset(-1) } } + val onNext: () -> Unit = remember(navigateByOffset) { { navigateByOffset(1) } } BoxWithConstraints { val width = constraints.maxWidth @@ -79,15 +115,27 @@ fun CalendarScroller( openDayPicker = openDayPicker, currentDateFormatted = currentDateFormatted, ) { - DayContentFrame { - CalendarDayPortrait( - day = currentDay, - videoAspectRatio = videoAspectRatio, - onRecordTodayVideoClick = onRecordTodayVideoClick, - onDeleteTodayVideoClick = onDeleteTodayVideoClick, - videoPlayerController = videoPlayerController, - share = share, - ) + HorizontalPager( + state = pagerState, + beyondBoundsPageCount = 0, + modifier = Modifier.fillMaxSize(), + ) { pageIndex -> + val day = days.getOrNull(pageIndex) ?: return@HorizontalPager + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + ) { + CalendarDayPortrait( + day = day, + videoAspectRatio = videoAspectRatio, + onRecordTodayVideoClick = onRecordTodayVideoClick, + onDeleteTodayVideoClick = onDeleteTodayVideoClick, + videoPlayerController = videoPlayerController, + share = share, + ) + } } } } else { @@ -98,21 +146,34 @@ fun CalendarScroller( openDayPicker = openDayPicker, currentDateFormatted = currentDateFormatted, ) { - DayContentFrame { - CalendarDayLandscape( - day = currentDay, - videoAspectRatio = videoAspectRatio, - onRecordTodayVideoClick = onRecordTodayVideoClick, - onDeleteTodayVideoClick = onDeleteTodayVideoClick, - videoPlayerController = videoPlayerController, - share = share, - ) + HorizontalPager( + state = pagerState, + beyondBoundsPageCount = 0, + modifier = Modifier.fillMaxSize(), + ) { pageIndex -> + val day = days.getOrNull(pageIndex) ?: return@HorizontalPager + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + ) { + CalendarDayLandscape( + day = day, + videoAspectRatio = videoAspectRatio, + onRecordTodayVideoClick = onRecordTodayVideoClick, + onDeleteTodayVideoClick = onDeleteTodayVideoClick, + videoPlayerController = videoPlayerController, + share = share, + ) + } } } } } } +@OptIn(ExperimentalFoundationApi::class) @Preview @Composable internal fun PreviewCalendarScroller() {