Librechat-Mobile/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt

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(),
)
}
}