Skip to content
Open
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Day>,
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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() {
Expand Down
Loading