package com.garfiec.librechat.navigation import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.garfiec.librechat.core.model.Banner import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.shared.navigation.NavHostViewModel import com.garfiec.librechat.shared.navigation.Navigator import com.garfiec.librechat.shared.navigation.MainNavDisplay import com.garfiec.librechat.shared.navigation.SidebarScaffold import com.garfiec.librechat.shared.navigation.toRoute import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace import com.garfiec.librechat.feature.chat.navigation.NewChat import com.garfiec.librechat.feature.files.navigation.Files import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed import kotlinx.coroutines.launch private val SidebarWidth = 320.dp /** Velocity (px/s) above which a fling commits the gesture regardless of position. */ private const val FLING_VELOCITY_THRESHOLD = 800f @Composable fun TabletLayout( navigator: Navigator, navHostViewModel: NavHostViewModel, modifier: Modifier = Modifier, ) { // Banner state only -- drawer state is collected inside DrawerContent itself val banners by navHostViewModel.banners.collectAsStateWithLifecycle() val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() // Persisted sidebar state from DataStore -- single source of truth in the ViewModel. val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle() // Whether swipe gesture is enabled (from settings) val gestureEnabled by navHostViewModel.tabletSidebarGestureEnabled.collectAsStateWithLifecycle() val density = LocalDensity.current val sidebarWidthPx = with(density) { SidebarWidth.toPx() } // Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open. val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) } val scope = rememberCoroutineScope() // Back press closes sidebar before navigating away BackHandler(enabled = isSidebarOpen) { navHostViewModel.setTabletSidebarOpen(false) } // Sync: when ViewModel state changes (e.g. hamburger button, back press), // animate the sidebar to match. LaunchedEffect(isSidebarOpen) { val target = if (isSidebarOpen) sidebarWidthPx else 0f if (sidebarOffset.value != target) { sidebarOffset.animateTo( targetValue = target, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, ), ) } } if (!navigator.isInAuthFlow) { val swipeModifier = if (gestureEnabled) { Modifier.pointerInput(Unit) { val velocityTracker = VelocityTracker() detectHorizontalDragGestures( onDragStart = { velocityTracker.resetTracking() }, onDragEnd = { val velocity = velocityTracker.calculateVelocity().x val currentOffset = sidebarOffset.value val shouldOpen = when { velocity > FLING_VELOCITY_THRESHOLD -> true velocity < -FLING_VELOCITY_THRESHOLD -> false else -> currentOffset > sidebarWidthPx * 0.5f } scope.launch { sidebarOffset.animateTo( targetValue = if (shouldOpen) sidebarWidthPx else 0f, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, ), ) navHostViewModel.setTabletSidebarOpen(shouldOpen) } }, onDragCancel = { val target = if (isSidebarOpen) sidebarWidthPx else 0f scope.launch { sidebarOffset.animateTo( targetValue = target, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, ), ) } }, onHorizontalDrag = { change, dragAmount -> change.consume() velocityTracker.addPosition( change.uptimeMillis, change.position, ) scope.launch { val newOffset = (sidebarOffset.value + dragAmount) .coerceIn(0f, sidebarWidthPx) sidebarOffset.snapTo(newOffset) } }, ) } } else { Modifier } Layout( content = { // Slot 0: Sidebar -- always 320dp, slides from off-screen left to x=0 Row(modifier = Modifier.fillMaxHeight()) { SidebarScaffold( viewModel = navHostViewModel, onNewChat = { if (navigator.currentRoute !is NewChat) { navigator.navigateToTopLevel(NewChat) } }, onConversationClick = { conversationId -> navigator.navigateToChat(conversationId) }, onSettingsClick = { navigator.navigate(SettingsTabbed) }, onSettingsCategorySelected = { category -> navigator.navigate(category.toRoute()) }, onAgentsClick = { navigator.navigate(AgentMarketplace) }, onFilesClick = { navigator.navigate(Files) }, modifier = Modifier .width(SidebarWidth) .fillMaxHeight(), ) VerticalDivider( color = MaterialTheme.colorScheme.outlineVariant, ) } // Slot 1: Main content -- resizes to fill remaining space MainContent( navigator = navigator, navHostViewModel = navHostViewModel, isInAuthFlow = false, banners = banners, dismissedBannerIds = dismissedBannerIds, onToggleDrawer = { navHostViewModel.setTabletSidebarOpen(!isSidebarOpen) }, ) }, modifier = modifier.fillMaxSize().then(swipeModifier).clipToBounds(), ) { measurables, constraints -> val sidebarPx = SidebarWidth.roundToPx() val animatedPx = sidebarOffset.value.toInt() val sidebarPlaceable = measurables[0].measure( constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx), ) val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0) val mainPlaceable = measurables[1].measure( constraints.copy(minWidth = mainWidth, maxWidth = mainWidth), ) layout(constraints.maxWidth, constraints.maxHeight) { sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0) mainPlaceable.placeRelative(animatedPx, 0) } } } else { // Auth flow -- no sidebar, just main content MainContent( navigator = navigator, navHostViewModel = navHostViewModel, isInAuthFlow = true, banners = banners, dismissedBannerIds = dismissedBannerIds, onToggleDrawer = {}, modifier = Modifier.fillMaxSize(), ) } } @Composable private fun MainContent( navigator: Navigator, navHostViewModel: NavHostViewModel, isInAuthFlow: Boolean, banners: List, dismissedBannerIds: Set, onToggleDrawer: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { if (!isInAuthFlow && banners.isNotEmpty()) { BannerDisplay( banners = banners, dismissedIds = dismissedBannerIds, onDismiss = navHostViewModel::dismissBanner, modifier = Modifier.padding(top = 4.dp), ) } MainNavDisplay( navigator = navigator, navHostViewModel = navHostViewModel, onMenuClick = onToggleDrawer, modifier = Modifier.fillMaxSize(), ) } }