519 lines
19 KiB
Kotlin
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,
|
|
)
|
|
}
|
|
}
|