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

519 lines
19 KiB
Kotlin

package com.garfiec.librechat.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.garfiec.librechat.R
import com.garfiec.librechat.core.model.EModelEndpoint
import com.garfiec.librechat.core.ui.components.isMonochromeIcon
import com.garfiec.librechat.core.ui.components.toIconRes
/**
* Lightweight snapshot of fields DrawerConversationItem actually renders.
* Avoids passing the full 28-field Conversation through composition.
*/
@Immutable
data class DrawerConversationDisplayData(
val conversationId: String,
val title: String,
val model: String?,
val endpoint: EModelEndpoint?,
val relativeTime: String,
val isActive: Boolean,
val isFavorite: Boolean,
)
// Pre-computed shapes to avoid creating new ones per item per frame
private val ItemShape = RoundedCornerShape(8.dp)
private val ActiveIndicatorShape = RoundedCornerShape(2.dp)
/**
* Stateful DrawerContent that collects its own state from the ViewModel.
* State changes only recompose inside this composable — not the parent
* PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content.
*/
@Composable
fun DrawerContent(
viewModel: NavHostViewModel,
onNewChat: () -> Unit,
onConversationClick: (String) -> Unit,
onSettingsClick: () -> Unit,
onAgentsClick: () -> Unit,
onFilesClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by viewModel.drawerUiState.collectAsStateWithLifecycle()
DrawerContent(
uiState = uiState,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
onNewChat = onNewChat,
onConversationClick = onConversationClick,
onSettingsClick = onSettingsClick,
onAgentsClick = onAgentsClick,
onFilesClick = onFilesClick,
onToggleFavorite = viewModel::toggleFavorite,
onRefresh = viewModel::refreshConversations,
onLoadMore = viewModel::loadMoreConversations,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrawerContent(
uiState: DrawerUiState,
onSearchQueryChanged: (String) -> Unit,
onNewChat: () -> Unit,
onConversationClick: (String) -> Unit,
onSettingsClick: () -> Unit,
onAgentsClick: () -> Unit,
onFilesClick: () -> Unit,
modifier: Modifier = Modifier,
onToggleFavorite: (String) -> Unit = {},
onRefresh: () -> Unit = {},
onLoadMore: () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxHeight()
.width(300.dp)
.statusBarsPadding()
.padding(top = 16.dp),
) {
// "New Chat" button at top
Surface(
onClick = onNewChat,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
shape = ItemShape,
color = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.new_chat),
style = MaterialTheme.typography.titleSmall,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Search bar
OutlinedTextField(
value = uiState.searchQuery,
onValueChange = onSearchQueryChanged,
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.cd_search),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
trailingIcon = {
if (uiState.searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchQueryChanged("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cd_clear_search),
modifier = Modifier.size(20.dp),
)
}
}
},
placeholder = {
Text(
text = stringResource(R.string.search_conversations_placeholder),
style = MaterialTheme.typography.bodySmall,
)
},
singleLine = true,
shape = ItemShape,
textStyle = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
)
Spacer(modifier = Modifier.height(8.dp))
// Conversation list with favorites section and date groups
val listState = rememberLazyListState()
// Detect when scrolled near the end to trigger load-more
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisibleItem >= totalItems - 8 && totalItems > 0
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value && uiState.hasMore && !uiState.isLoadingMore) {
onLoadMore()
}
}
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f),
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
// Favorites section
if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) {
item(key = "favorites_header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 4.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.favorites),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics { heading() },
)
}
}
items(
items = uiState.favoriteConversations,
key = { "fav_${it.conversationId}" },
contentType = { "conversation" },
) { data ->
DrawerConversationItem(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) },
)
}
item(key = "favorites_divider") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
if (uiState.groupedConversations.isEmpty() && uiState.searchQuery.isNotEmpty()) {
item(key = "empty_search") {
Text(
text = stringResource(R.string.no_conversations_found),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
)
}
}
uiState.groupedConversations.forEach { (dateGroup, displayItems) ->
// Date group header
item(key = "header_$dateGroup") {
Text(
text = dateGroup,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 12.dp,
bottom = 4.dp,
),
)
}
// Conversation items in this group
items(
items = displayItems,
key = { it.conversationId },
contentType = { "conversation" },
) { data ->
DrawerConversationItem(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) },
)
}
}
// Loading indicator at the bottom when loading more
if (uiState.isLoadingMore) {
item(key = "loading_more") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
}
}
}
}
// Bottom section: divider + footer links + sign out
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
// Footer navigation links
DrawerFooterItem(
icon = Icons.Default.SmartToy,
label = stringResource(R.string.agents),
onClick = onAgentsClick,
)
DrawerFooterItem(
icon = Icons.Default.Folder,
label = stringResource(R.string.files),
onClick = onFilesClick,
)
DrawerFooterItem(
icon = Icons.Default.Settings,
label = stringResource(R.string.settings),
onClick = onSettingsClick,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun DrawerConversationItem(
data: DrawerConversationDisplayData,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onToggleFavorite: () -> Unit = {},
) {
val endpointIconRes = remember(data.endpoint) {
data.endpoint?.toIconRes()
}
val backgroundColor = if (data.isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surface
}
Row(
modifier = modifier
.padding(horizontal = 4.dp, vertical = 1.dp)
.fillMaxWidth()
.background(backgroundColor, ItemShape)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Active indicator (left border simulation)
if (data.isActive) {
Box(
modifier = Modifier
.width(3.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.primary, ActiveIndicatorShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
// Endpoint icon or star for favorites
if (data.isFavorite) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else if (endpointIconRes != null) {
val isMonochrome = data.endpoint?.isMonochromeIcon() == true
Icon(
painter = painterResource(id = endpointIconRes),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (isMonochrome) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
Color.Unspecified
},
)
} else {
Icon(
imageVector = Icons.Default.SmartToy,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (data.isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Spacer(modifier = Modifier.width(10.dp))
// Title and metadata
Column(modifier = Modifier.weight(1f)) {
Text(
text = data.title,
style = MaterialTheme.typography.bodyMedium,
color = if (data.isActive) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Model and time on second line
val subtitle = remember(data.model, data.relativeTime) {
buildString {
data.model?.let { model ->
append(model.take(20))
}
if (data.relativeTime.isNotEmpty()) {
if (isNotEmpty()) append(" \u00B7 ")
append(data.relativeTime)
}
}
}
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Bookmark toggle — lightweight clickable icon instead of IconButton
Icon(
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (data.isFavorite) stringResource(R.string.remove_bookmark) else stringResource(R.string.bookmark),
modifier = Modifier
.size(32.dp)
.clickable(onClick = onToggleFavorite)
.padding(8.dp),
tint = if (data.isFavorite) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@Composable
private fun DrawerFooterItem(
icon: ImageVector,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}