251 lines
10 KiB
Kotlin
251 lines
10 KiB
Kotlin
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<Banner>,
|
|
dismissedBannerIds: Set<String>,
|
|
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(),
|
|
)
|
|
}
|
|
}
|