Merge pull request #14 from garfiec/refactor/consolidate-navigation
refactor: consolidate duplicate navigation code between app and shared modules
This commit is contained in:
commit
c8a98f9962
20 changed files with 213 additions and 1818 deletions
|
|
@ -4,36 +4,34 @@ Single Activity architecture. `MainActivity` is the sole entry point.
|
|||
|
||||
## Navigation
|
||||
|
||||
`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack<NavKey>` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`:
|
||||
`LibreChatNavHost` (in this module) is the Android-specific entry point. It wraps the shared module's `LibreChatNavHost` via a `content` lambda, adding:
|
||||
|
||||
- **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture.
|
||||
- **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar.
|
||||
- **Deep link handling** (`librechat://conversation/{conversationId}`)
|
||||
- **Share intent routing** (navigates to NewChat if not already on a chat screen)
|
||||
- **Tablet layout branching** based on `WindowSizeClass`
|
||||
|
||||
The shared module owns the core navigation: `Navigator`, `NavHostViewModel`, `MainNavDisplay`, `PhoneLayout`, sidebar/drawer composables, and all feature entry providers. See `shared/CLAUDE.md` for details.
|
||||
|
||||
### Layout Modes
|
||||
|
||||
- **Phone**: Delegates to shared `PhoneLayout` — `ModalNavigationDrawer` sidebar-first pattern.
|
||||
- **Tablet** (600dp+ width): Uses `TabletLayout` (Android-only, in this module) — persistent side panel with swipe gesture and `BackHandler`.
|
||||
|
||||
Feature modules provide entries via `EntryProviderScope<NavKey>` extensions (e.g., `authEntries()`, `chatEntries()`). Navigation is driven by `onNavigate: (NavKey) -> Unit` and `onBack: () -> Unit` lambdas — feature modules never receive `NavBackStack` directly.
|
||||
|
||||
## Sidebar / Drawer Content
|
||||
## Android-Only Files in This Module
|
||||
|
||||
`DrawerContent` is the rich sidebar matching the web's left panel:
|
||||
- "New Chat" button at top
|
||||
- Search bar with debounce filtering
|
||||
- Conversation items grouped by date (Today, Yesterday, Previous 7 Days, etc.)
|
||||
- Each item shows: endpoint icon, title, model name, relative time
|
||||
- Active conversation highlighted with left border indicator and tinted background
|
||||
- Footer: links to Agents, Files, Settings
|
||||
- Sign out button at bottom
|
||||
- `LibreChatNavHost.kt` — Thin wrapper adding deep links, share intents, and tablet/phone branching
|
||||
- `TabletLayout.kt` — Persistent sidebar with `BackHandler`, `android.net.Uri`, and custom swipe gesture
|
||||
|
||||
## Top-Level Destinations
|
||||
|
||||
Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings).
|
||||
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider.
|
||||
|
||||
## Active Conversation Tracking
|
||||
|
||||
`NavHostViewModel.activeConversationId` tracks the currently viewed conversation. Updated automatically from the navigation back stack when the chat route changes. Used by `DrawerContent` to highlight the active conversation.
|
||||
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the shared `MainNavDisplay` entry provider.
|
||||
|
||||
## Auth & Session
|
||||
|
||||
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
|
||||
- `NavHostViewModel` (in shared) observes auth state via `isLoggedIn` flow
|
||||
- Start destination is `NewChat` if logged in, `ServerUrl` otherwise
|
||||
- `sessionExpired` flow triggers nav to auth flow with full back stack clear
|
||||
- Drawer gestures are hidden during auth flow
|
||||
|
|
@ -41,7 +39,7 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
|
|||
## Deep Linking
|
||||
|
||||
- Scheme: `librechat://conversation/{conversationId}`
|
||||
- Handled in `MainActivity.handleDeepLink()` and forwarded to `LibreChatNavHost`
|
||||
- Handled in `MainActivity.handleDeepLink()` and forwarded to this module's `LibreChatNavHost`
|
||||
- `onNewIntent` handles deep links when app is already running
|
||||
|
||||
## Connectivity
|
||||
|
|
@ -51,23 +49,22 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
|
|||
|
||||
## Dependencies
|
||||
|
||||
This module depends on all `:core:*` and all `:feature:*` modules.
|
||||
This module depends on `:shared`, all `:core:*`, and all `:feature:*` modules.
|
||||
It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`.
|
||||
|
||||
### Server-Synced Favorites
|
||||
- `NavHostViewModel` exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
|
||||
- `NavHostViewModel` (shared) exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
|
||||
- Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring
|
||||
- `DrawerContent` shows star icon per conversation (filled = bookmarked)
|
||||
- Favorites state passed through `LibreChatNavHost` and `TabletLayout`
|
||||
- `DrawerContent` (shared) shows star icon per conversation (filled = bookmarked)
|
||||
|
||||
### Server Banners
|
||||
- `NavHostViewModel` fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
|
||||
- `NavHostViewModel` (shared) fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
|
||||
- Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow<Set<String>>` (session-scoped, not persisted)
|
||||
- `BannerDisplay` composable shown at top of content in both `LibreChatNavHost` (phone) and `TabletLayout`
|
||||
- `BannerDisplay` composable shown at top of content in both `PhoneLayout` (shared) and `TabletLayout` (app)
|
||||
- Banner types: "info" (blue), "warning" (amber), "error" (red) — defaults to "info" if type is null
|
||||
- **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed
|
||||
|
||||
### Preset Navigation
|
||||
- `PresetManager` route registered in `SettingsNavigation.kt`
|
||||
- `PresetManagerScreen` accessible from Settings → Chat section
|
||||
- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts
|
||||
- Navigation wired through shared `MainNavDisplay` entry provider, accessible from both phone and tablet layouts
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:network"))
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import com.garfiec.librechat.feature.chat.di.chatModule
|
|||
import com.garfiec.librechat.feature.conversations.di.conversationsModule
|
||||
import com.garfiec.librechat.feature.files.di.filesModule
|
||||
import com.garfiec.librechat.feature.settings.di.settingsModule
|
||||
import com.garfiec.librechat.navigation.appModule
|
||||
import com.garfiec.librechat.shared.navigation.sharedAppModule
|
||||
import io.ktor.client.HttpClient
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
|
|
@ -41,7 +41,7 @@ class LibreChatApplication : Application(), SingletonImageLoader.Factory {
|
|||
commonModule,
|
||||
networkModule,
|
||||
dataModule,
|
||||
appModule,
|
||||
sharedAppModule,
|
||||
authModule,
|
||||
authPlatformModule,
|
||||
chatModule,
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
viewModelOf(::NavHostViewModel)
|
||||
single<SerializersModule>(named("navigation")) { navigationSerializersModule }
|
||||
}
|
||||
|
|
@ -1,519 +0,0 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +1,22 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import com.garfiec.librechat.MainActivity
|
||||
import com.garfiec.librechat.R
|
||||
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
|
||||
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
|
||||
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||
import com.garfiec.librechat.feature.auth.navigation.authEntries
|
||||
import com.garfiec.librechat.shared.navigation.LibreChatNavHost as SharedLibreChatNavHost
|
||||
import com.garfiec.librechat.shared.navigation.PhoneLayout
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatEntries
|
||||
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
|
||||
import com.garfiec.librechat.feature.files.navigation.Files
|
||||
import com.garfiec.librechat.feature.files.navigation.filesEntries
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsData
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
|
||||
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
|
||||
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
|
||||
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
||||
SettingsCategory.GENERAL -> SettingsGeneral
|
||||
SettingsCategory.CHAT -> SettingsChat
|
||||
SettingsCategory.ACCOUNT -> SettingsAccount
|
||||
SettingsCategory.DATA -> SettingsData
|
||||
}
|
||||
|
||||
/**
|
||||
* Android-specific entry point that wraps the shared [SharedLibreChatNavHost]
|
||||
* with deep link handling, share intent support, and tablet layout branching.
|
||||
*/
|
||||
@Composable
|
||||
fun LibreChatNavHost(
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -81,296 +24,50 @@ fun LibreChatNavHost(
|
|||
deepLinkUri: Uri? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
shareNavigationTrigger: Int = 0,
|
||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||
) {
|
||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||
|
||||
// Stable start key — auth redirect handled via LaunchedEffect below.
|
||||
val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
|
||||
val navigator = Navigator(backStack)
|
||||
|
||||
// Redirect to auth if not logged in (on first composition only).
|
||||
LaunchedEffect(Unit) {
|
||||
if (!navHostViewModel.isLoggedIn.value) {
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
// Track active conversation from nav back stack
|
||||
LaunchedEffect(navigator.currentRoute) {
|
||||
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
|
||||
navHostViewModel.setActiveConversation(conversationId)
|
||||
}
|
||||
|
||||
// Handle session expiry
|
||||
LaunchedEffect(Unit) {
|
||||
navHostViewModel.sessionExpired.collect {
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
val isTablet = windowSizeClass?.widthSizeClass?.let {
|
||||
it >= WindowWidthSizeClass.Medium
|
||||
} ?: false
|
||||
|
||||
if (isTablet) {
|
||||
TabletLayout(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
deepLinkUri = deepLinkUri,
|
||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||
shareNavigationTrigger = shareNavigationTrigger,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
PhoneLayout(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
deepLinkUri = deepLinkUri,
|
||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||
shareNavigationTrigger = shareNavigationTrigger,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
// Version mismatch warning dialog (shown over any layout)
|
||||
val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle()
|
||||
versionMismatch?.let { mismatch ->
|
||||
VersionMismatchDialog(
|
||||
supportedVersion = mismatch.supportedVersion,
|
||||
backendVersion = mismatch.backendVersion,
|
||||
onDismiss = navHostViewModel::dismissVersionWarning,
|
||||
onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog shown when the backend server version does not match the version
|
||||
* this app was built for. Offers "Dismiss" (session-only) and
|
||||
* "Don't warn again" (persisted per backend version) options.
|
||||
*/
|
||||
@Composable
|
||||
private fun VersionMismatchDialog(
|
||||
supportedVersion: String,
|
||||
backendVersion: String,
|
||||
onDismiss: () -> Unit,
|
||||
onDismissPermanently: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.version_mismatch_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.version_mismatch_message, supportedVersion, backendVersion),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dismiss))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissPermanently) {
|
||||
Text(stringResource(R.string.dont_warn_again))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneLayout(
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
deepLinkUri: Uri?,
|
||||
onDeepLinkConsumed: () -> Unit,
|
||||
shareNavigationTrigger: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
||||
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||
|
||||
// Handle deep links
|
||||
LaunchedEffect(deepLinkUri) {
|
||||
deepLinkUri?.let { uri ->
|
||||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||
uri.lastPathSegment?.let { conversationId ->
|
||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||
navigator.navigateToChat(conversationId)
|
||||
} else {
|
||||
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
||||
SharedLibreChatNavHost(modifier = modifier) { navigator, navHostViewModel, mod ->
|
||||
// Handle deep links
|
||||
LaunchedEffect(deepLinkUri) {
|
||||
deepLinkUri?.let { uri ->
|
||||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||
uri.lastPathSegment?.let { conversationId ->
|
||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||
navigator.navigateToChat(conversationId)
|
||||
} else {
|
||||
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onDeepLinkConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle share intent
|
||||
LaunchedEffect(shareNavigationTrigger) {
|
||||
if (shareNavigationTrigger > 0) {
|
||||
val currentRoute = navigator.currentRoute
|
||||
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||
if (!isOnChatScreen) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
onDeepLinkConsumed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset sidebar mode to Conversations when the drawer closes
|
||||
LaunchedEffect(drawerState.isClosed) {
|
||||
if (drawerState.isClosed) {
|
||||
navHostViewModel.setSidebarMode(SidebarMode.Conversations)
|
||||
// Handle share intent
|
||||
LaunchedEffect(shareNavigationTrigger) {
|
||||
if (shareNavigationTrigger > 0) {
|
||||
val currentRoute = navigator.currentRoute
|
||||
if (currentRoute !is Chat && currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !navigator.isInAuthFlow,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
SidebarScaffold(
|
||||
viewModel = navHostViewModel,
|
||||
onNewChat = {
|
||||
scope.launch { drawerState.close() }
|
||||
if (navigator.currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
},
|
||||
onConversationClick = { conversationId ->
|
||||
scope.launch { drawerState.close() }
|
||||
navigator.navigateToChat(conversationId)
|
||||
},
|
||||
onSettingsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navigator.navigate(SettingsTabbed)
|
||||
},
|
||||
onSettingsCategorySelected = { category ->
|
||||
navigator.navigate(category.toRoute())
|
||||
},
|
||||
onAgentsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navigator.navigate(AgentMarketplace)
|
||||
},
|
||||
onFilesClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navigator.navigate(Files)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
|
||||
BannerDisplay(
|
||||
banners = banners,
|
||||
dismissedIds = dismissedBannerIds,
|
||||
onDismiss = navHostViewModel::dismissBanner,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
MainNavDisplay(
|
||||
val isTablet = windowSizeClass?.widthSizeClass?.let {
|
||||
it >= WindowWidthSizeClass.Medium
|
||||
} ?: false
|
||||
|
||||
if (isTablet) {
|
||||
TabletLayout(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = mod,
|
||||
)
|
||||
} else {
|
||||
PhoneLayout(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
modifier = mod,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MainNavDisplay(
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = navigator.backStack,
|
||||
onBack = { navigator.goBack() },
|
||||
modifier = modifier,
|
||||
transitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
popTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
authEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onAuthComplete = {
|
||||
navHostViewModel.onAuthComplete()
|
||||
navigator.navigateToChat()
|
||||
},
|
||||
)
|
||||
chatEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onNavigateToChat = { navigator.navigateToChat(it) },
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
)
|
||||
conversationsEntries(
|
||||
onConversationClick = { navigator.navigateToChat(it) },
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
agentsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onStartChat = { _ -> navigator.navigateToTopLevel(NewChat) },
|
||||
)
|
||||
filesEntries(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
settingsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onLogout = {
|
||||
navHostViewModel.logout()
|
||||
navigator.navigateToAuth()
|
||||
},
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
)
|
||||
memoriesEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
mcpServersEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,457 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
||||
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
|
||||
import com.garfiec.librechat.core.common.result.Result
|
||||
import com.garfiec.librechat.core.data.repository.AuthRepository
|
||||
import com.garfiec.librechat.core.data.repository.BannerRepository
|
||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||
import com.garfiec.librechat.core.model.Banner
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.network.client.TokenManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
/**
|
||||
* Combined UI state for the drawer sidebar. A single emission replaces
|
||||
* 10+ individual StateFlow collections in the parent composable, preventing
|
||||
* unnecessary recomposition of the entire PhoneLayout/TabletLayout tree.
|
||||
*/
|
||||
@Immutable
|
||||
data class DrawerUiState(
|
||||
val groupedConversations: List<Pair<String, List<DrawerConversationDisplayData>>> = emptyList(),
|
||||
val favoriteConversations: List<DrawerConversationDisplayData> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val isRefreshing: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val hasMore: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* UI state for a backend version mismatch warning.
|
||||
*/
|
||||
@Immutable
|
||||
data class VersionMismatchState(
|
||||
val supportedVersion: String,
|
||||
val backendVersion: String,
|
||||
)
|
||||
|
||||
class NavHostViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
private val bannerRepository: BannerRepository,
|
||||
private val configRepository: ConfigRepository,
|
||||
private val conversationRepository: ConversationRepository,
|
||||
private val tokenManager: TokenManager,
|
||||
private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
/**
|
||||
* Non-null when a version mismatch has been detected and should be shown to the user.
|
||||
* Set to null after the user dismisses the dialog (for this session or permanently).
|
||||
*/
|
||||
private val _versionMismatch = MutableStateFlow<VersionMismatchState?>(null)
|
||||
val versionMismatch: StateFlow<VersionMismatchState?> = _versionMismatch.asStateFlow()
|
||||
|
||||
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
|
||||
|
||||
/** Conversations grouped by date (internal, feeds into drawerUiState). */
|
||||
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
|
||||
|
||||
/** The ID of the currently viewed conversation (for active highlight). */
|
||||
private val _activeConversationId = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Drawer search query. */
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
|
||||
/** Server-synced bookmarked conversation IDs. */
|
||||
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Bookmarked/favorite conversations (internal, feeds into drawerUiState). */
|
||||
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
|
||||
|
||||
/** Pagination cursor for loading more conversations from the server. */
|
||||
private var nextCursor: String? = null
|
||||
|
||||
/** Whether there are more conversations to load from the server. */
|
||||
private val _hasMore = MutableStateFlow(true)
|
||||
|
||||
/** Whether a load-more request is in progress. */
|
||||
private val _isLoadingMore = MutableStateFlow(false)
|
||||
|
||||
/** Whether a pull-to-refresh is in progress. */
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
|
||||
/** Active banners from the server, filtered to non-expired. */
|
||||
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
|
||||
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
|
||||
|
||||
/** Banner IDs dismissed by the user during this session. */
|
||||
private val _dismissedBannerIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val dismissedBannerIds: StateFlow<Set<String>> = _dismissedBannerIds.asStateFlow()
|
||||
|
||||
val sessionExpired: SharedFlow<Unit> = tokenManager.sessionExpiredFlow
|
||||
|
||||
/** Persisted sidebar open/close state for tablet mode.
|
||||
* The initial value is read synchronously so that first composition
|
||||
* sees the real persisted state instead of a hardcoded default,
|
||||
* avoiding a visible open-then-close flicker on cold start. */
|
||||
val tabletSidebarOpen: StateFlow<Boolean> = settingsDataStore.tabletSidebarOpen
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
runBlocking { settingsDataStore.tabletSidebarOpen.first() },
|
||||
)
|
||||
|
||||
/** Whether the swipe gesture to open/close the tablet sidebar is enabled. */
|
||||
val tabletSidebarGestureEnabled: StateFlow<Boolean> = settingsDataStore.tabletSidebarGestureEnabled
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
/** Current sidebar mode: Conversations (default) or Settings. */
|
||||
private val _sidebarMode = MutableStateFlow<SidebarMode>(SidebarMode.Conversations)
|
||||
val sidebarMode: StateFlow<SidebarMode> = _sidebarMode.asStateFlow()
|
||||
|
||||
/** Currently selected settings category in the sidebar nav menu. */
|
||||
private val _selectedSettingsCategory = MutableStateFlow<SettingsCategory?>(null)
|
||||
val selectedSettingsCategory: StateFlow<SettingsCategory?> = _selectedSettingsCategory.asStateFlow()
|
||||
|
||||
fun setSidebarMode(mode: SidebarMode) {
|
||||
_sidebarMode.value = mode
|
||||
}
|
||||
|
||||
fun selectSettingsCategory(category: SettingsCategory) {
|
||||
_selectedSettingsCategory.value = category
|
||||
}
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
/**
|
||||
* Single combined drawer UI state. All display-data mapping (Conversation →
|
||||
* DrawerConversationDisplayData) happens here in the ViewModel, not in composition.
|
||||
* Collected inside DrawerContent so state changes only recompose the drawer,
|
||||
* not the entire PhoneLayout/TabletLayout.
|
||||
*/
|
||||
val drawerUiState: StateFlow<DrawerUiState> = combine(
|
||||
combine(
|
||||
_groupedConversations,
|
||||
_activeConversationId,
|
||||
_favorites,
|
||||
_favoriteConversations,
|
||||
_searchQuery,
|
||||
) { grouped, activeId, favIds, favConvos, query ->
|
||||
DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query)
|
||||
},
|
||||
combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore ->
|
||||
Triple(refreshing, loadingMore, hasMore)
|
||||
},
|
||||
) { data, (refreshing, loadingMore, hasMore) ->
|
||||
DrawerUiState(
|
||||
groupedConversations = data.grouped.map { (group, convos) ->
|
||||
group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }
|
||||
},
|
||||
favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) },
|
||||
searchQuery = data.query,
|
||||
isRefreshing = refreshing,
|
||||
isLoadingMore = loadingMore,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val loggedIn = authRepository.isLoggedIn()
|
||||
_isLoggedIn.value = loggedIn
|
||||
// Check backend version on cold start for returning users
|
||||
if (loggedIn) {
|
||||
checkBackendVersion()
|
||||
}
|
||||
}
|
||||
observeConversations()
|
||||
observeBookmarks()
|
||||
fetchBanners()
|
||||
loadInitialConversations()
|
||||
}
|
||||
|
||||
private fun observeConversations() {
|
||||
viewModelScope.launch {
|
||||
conversationRepository.observeConversations().collect { result ->
|
||||
if (result is Result.Success) {
|
||||
_recentConversations.value = result.data
|
||||
// Only regroup if not in search mode
|
||||
if (_searchQuery.value.isBlank()) {
|
||||
_groupedConversations.value = groupConversationsByDate(result.data)
|
||||
}
|
||||
updateFavoriteConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInitialConversations() {
|
||||
viewModelScope.launch {
|
||||
val result = conversationRepository.loadNextPage(cursor = null)
|
||||
if (result is Result.Success) {
|
||||
nextCursor = result.data
|
||||
_hasMore.value = result.data != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMoreConversations() {
|
||||
if (_isLoadingMore.value || !_hasMore.value) return
|
||||
_isLoadingMore.value = true
|
||||
viewModelScope.launch {
|
||||
val result = conversationRepository.loadNextPage(cursor = nextCursor)
|
||||
if (result is Result.Success) {
|
||||
nextCursor = result.data
|
||||
_hasMore.value = result.data != null
|
||||
} else {
|
||||
Logger.w { "Failed to load more conversations" }
|
||||
}
|
||||
_isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun onAuthComplete() {
|
||||
_isLoggedIn.value = true
|
||||
loadInitialConversations()
|
||||
fetchBanners()
|
||||
checkBackendVersion()
|
||||
}
|
||||
|
||||
fun refreshConversations() {
|
||||
_isRefreshing.value = true
|
||||
viewModelScope.launch {
|
||||
nextCursor = null
|
||||
_hasMore.value = true
|
||||
val result = conversationRepository.loadNextPage(cursor = null)
|
||||
if (result is Result.Success) {
|
||||
nextCursor = result.data
|
||||
_hasMore.value = result.data != null
|
||||
}
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun setActiveConversation(conversationId: String?) {
|
||||
_activeConversationId.value = conversationId
|
||||
}
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
_searchQuery.value = query
|
||||
searchJob?.cancel()
|
||||
|
||||
if (query.isBlank()) {
|
||||
// Reset to full grouped list from cached conversations
|
||||
_groupedConversations.value = groupConversationsByDate(_recentConversations.value)
|
||||
return
|
||||
}
|
||||
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(300) // debounce
|
||||
val filtered = _recentConversations.value.filter { conversation ->
|
||||
conversation.title?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
_groupedConversations.value = groupConversationsByDate(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeBookmarks() {
|
||||
viewModelScope.launch {
|
||||
settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds ->
|
||||
_favorites.value = bookmarkedIds
|
||||
updateFavoriteConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(conversationId: String) {
|
||||
viewModelScope.launch {
|
||||
settingsDataStore.toggleBookmark(conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFavoriteConversations() {
|
||||
val bookmarkedIds = _favorites.value
|
||||
val allConversations = _recentConversations.value
|
||||
_favoriteConversations.value = allConversations.filter { conversation ->
|
||||
conversation.conversationId in bookmarkedIds ||
|
||||
conversation.tags.any {
|
||||
it.equals("favorite", ignoreCase = true) ||
|
||||
it.equals("bookmark", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchBanners() {
|
||||
viewModelScope.launch {
|
||||
val result = bannerRepository.getBanners()
|
||||
if (result is Result.Success) {
|
||||
val now = Clock.System.now()
|
||||
_banners.value = result.data.filter { banner ->
|
||||
val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
val afterStart = from == null || now >= from
|
||||
val beforeEnd = to == null || now < to
|
||||
afterStart && beforeEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTabletSidebarOpen(open: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsDataStore.setTabletSidebarOpen(open)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissBanner(bannerId: String) {
|
||||
_dismissedBannerIds.value = _dismissedBannerIds.value + bannerId
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an async, non-blocking version check against the backend.
|
||||
* If the versions are incompatible and the user hasn't dismissed the warning
|
||||
* for this specific backend version, sets [versionMismatch] to trigger the dialog.
|
||||
*/
|
||||
private fun checkBackendVersion() {
|
||||
viewModelScope.launch {
|
||||
val result = configRepository.checkBackendVersion()
|
||||
if (result is Result.Success) {
|
||||
val checkResult = result.data
|
||||
val detectedVersion = checkResult.backendVersion
|
||||
if (!checkResult.isCompatible && detectedVersion != null) {
|
||||
// Check if the user has already dismissed the warning for this version
|
||||
val dismissedVersion = settingsDataStore.dismissedVersionWarning.first()
|
||||
if (dismissedVersion != detectedVersion) {
|
||||
_versionMismatch.value = VersionMismatchState(
|
||||
supportedVersion = checkResult.supportedVersion,
|
||||
backendVersion = detectedVersion,
|
||||
)
|
||||
} else {
|
||||
Logger.d {
|
||||
"Version mismatch (backend=$detectedVersion, supported=${checkResult.supportedVersion}) but user dismissed for this version"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// On error, silently ignore -- the version check is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the version mismatch dialog for this session only. */
|
||||
fun dismissVersionWarning() {
|
||||
_versionMismatch.value = null
|
||||
}
|
||||
|
||||
/** Dismiss the version mismatch dialog and suppress it for this backend version permanently. */
|
||||
fun dismissVersionWarningPermanently() {
|
||||
val backendVersion = _versionMismatch.value?.backendVersion
|
||||
_versionMismatch.value = null
|
||||
if (backendVersion != null) {
|
||||
viewModelScope.launch {
|
||||
settingsDataStore.setDismissedVersionWarning(backendVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
_isLoggedIn.value = false
|
||||
_recentConversations.value = emptyList()
|
||||
_groupedConversations.value = emptyList()
|
||||
_activeConversationId.value = null
|
||||
_searchQuery.value = ""
|
||||
_favorites.value = emptySet()
|
||||
_favoriteConversations.value = emptyList()
|
||||
_sidebarMode.value = SidebarMode.Conversations
|
||||
_selectedSettingsCategory.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupConversationsByDate(
|
||||
conversations: List<Conversation>,
|
||||
): List<Pair<String, List<Conversation>>> {
|
||||
if (conversations.isEmpty()) return emptyList()
|
||||
return conversations
|
||||
.groupBy { conversation ->
|
||||
conversation.updatedAt
|
||||
?.toInstantOrNull()
|
||||
?.toRelativeDateGroup()
|
||||
?: "Unknown"
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Intermediate holder for the 5-argument combine. */
|
||||
private data class DrawerDataSnapshot(
|
||||
val grouped: List<Pair<String, List<Conversation>>>,
|
||||
val activeId: String?,
|
||||
val favIds: Set<String>,
|
||||
val favConvos: List<Conversation>,
|
||||
val query: String,
|
||||
)
|
||||
|
||||
private fun Conversation.toDrawerDisplayData(
|
||||
activeConversationId: String?,
|
||||
favoriteIds: Set<String>,
|
||||
): DrawerConversationDisplayData {
|
||||
val convId = conversationId ?: ""
|
||||
return DrawerConversationDisplayData(
|
||||
conversationId = convId,
|
||||
title = title ?: "New Chat",
|
||||
model = model,
|
||||
endpoint = endpoint,
|
||||
relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "",
|
||||
isActive = convId == activeConversationId,
|
||||
isFavorite = convId in favoriteIds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Instant.toRelativeTimeString(): String {
|
||||
val now = Clock.System.now()
|
||||
val duration = now - this
|
||||
val minutes = duration.inWholeMinutes
|
||||
val hours = duration.inWholeHours
|
||||
val days = duration.inWholeDays
|
||||
|
||||
return when {
|
||||
minutes < 1 -> "Just now"
|
||||
minutes < 60 -> "${minutes}m ago"
|
||||
hours < 24 -> "${hours}h ago"
|
||||
days < 7 -> "${days}d ago"
|
||||
else -> {
|
||||
val date = toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
val monthAbbr = when (date.monthNumber) {
|
||||
1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr"
|
||||
5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug"
|
||||
9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec"
|
||||
else -> ""
|
||||
}
|
||||
"$monthAbbr ${date.dayOfMonth}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import androidx.savedstate.serialization.SavedStateConfiguration
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule
|
||||
import com.garfiec.librechat.feature.auth.navigation.authSerializersModule
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsSerializersModule
|
||||
import com.garfiec.librechat.feature.files.navigation.filesSerializersModule
|
||||
import com.garfiec.librechat.feature.settings.navigation.settingsSerializersModule
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.plus
|
||||
|
||||
/**
|
||||
* Combined [SerializersModule] for all navigation route types.
|
||||
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
|
||||
* Used by Nav 3's SavedStateConfiguration for type-safe state saving.
|
||||
*/
|
||||
val navigationSerializersModule: SerializersModule =
|
||||
authSerializersModule +
|
||||
chatSerializersModule +
|
||||
conversationsSerializersModule +
|
||||
agentsSerializersModule +
|
||||
filesSerializersModule +
|
||||
settingsSerializersModule
|
||||
|
||||
/**
|
||||
* Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types.
|
||||
* Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms.
|
||||
*/
|
||||
val navigationSavedStateConfig = SavedStateConfiguration {
|
||||
serializersModule = navigationSerializersModule
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
|
||||
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
|
||||
/**
|
||||
* Encapsulates all back stack mutations for Nav 3 navigation.
|
||||
* Follows UDF: UI events → Navigator → back stack state → NavDisplay recomposition.
|
||||
*/
|
||||
class Navigator(val backStack: NavBackStack<NavKey>) {
|
||||
|
||||
val currentRoute: NavKey? get() = backStack.lastOrNull()
|
||||
|
||||
val isInAuthFlow: Boolean get() = currentRoute is AuthRoute
|
||||
|
||||
/** Navigate forward to a route. */
|
||||
fun navigate(route: NavKey) {
|
||||
backStack.add(route)
|
||||
}
|
||||
|
||||
/** Pop the top entry. Safe on empty stacks. */
|
||||
fun goBack() {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
|
||||
/** Navigate to a chat, replacing any current chat on the stack. */
|
||||
fun navigateToChat(conversationId: String) {
|
||||
if (backStack.lastOrNull() is Chat) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
backStack.add(Chat(conversationId))
|
||||
}
|
||||
|
||||
/** Navigate to a top-level destination, popping to root first. */
|
||||
fun navigateToTopLevel(route: NavKey) {
|
||||
while (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
if (backStack.lastOrNull()?.let { it::class } != route::class) {
|
||||
backStack.removeLastOrNull()
|
||||
backStack.add(route)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear back stack and navigate to auth (session expiry / logout). */
|
||||
fun navigateToAuth() {
|
||||
backStack.clear()
|
||||
backStack.add(ServerUrl)
|
||||
}
|
||||
|
||||
/** Clear back stack and navigate to chat (auth complete). */
|
||||
fun navigateToChat() {
|
||||
backStack.clear()
|
||||
backStack.add(NewChat)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.garfiec.librechat.R
|
||||
|
||||
/**
|
||||
* Settings sidebar content shown when the sidebar mode is [SidebarMode.Settings].
|
||||
* Displays a simple vertical list of settings categories. Tapping a category
|
||||
* navigates to its sub-page in the main content area.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsSidebarContent(
|
||||
selectedCategory: SettingsCategory?,
|
||||
onBackToConversations: () -> Unit,
|
||||
onCategorySelected: (SettingsCategory) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(300.dp)
|
||||
.statusBarsPadding()
|
||||
.padding(top = 8.dp),
|
||||
) {
|
||||
// Header with back arrow
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBackToConversations) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.cd_back_to_conversations),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.semantics { heading() },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
|
||||
// Category list
|
||||
SettingsCategoryItem(
|
||||
icon = Icons.Default.Settings,
|
||||
category = SettingsCategory.GENERAL,
|
||||
isSelected = selectedCategory == SettingsCategory.GENERAL,
|
||||
onClick = { onCategorySelected(SettingsCategory.GENERAL) },
|
||||
)
|
||||
SettingsCategoryItem(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
category = SettingsCategory.CHAT,
|
||||
isSelected = selectedCategory == SettingsCategory.CHAT,
|
||||
onClick = { onCategorySelected(SettingsCategory.CHAT) },
|
||||
)
|
||||
SettingsCategoryItem(
|
||||
icon = Icons.Default.AccountCircle,
|
||||
category = SettingsCategory.ACCOUNT,
|
||||
isSelected = selectedCategory == SettingsCategory.ACCOUNT,
|
||||
onClick = { onCategorySelected(SettingsCategory.ACCOUNT) },
|
||||
)
|
||||
SettingsCategoryItem(
|
||||
icon = Icons.Default.Storage,
|
||||
category = SettingsCategory.DATA,
|
||||
isSelected = selectedCategory == SettingsCategory.DATA,
|
||||
onClick = { onCategorySelected(SettingsCategory.DATA) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCategoryItem(
|
||||
icon: ImageVector,
|
||||
category: SettingsCategory,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val contentColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(backgroundColor, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = contentColor,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = category.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
sealed interface SidebarMode {
|
||||
data object Conversations : SidebarMode
|
||||
data object Settings : SidebarMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings categories shown as a navigation menu in the sidebar.
|
||||
* Each maps to a dedicated settings sub-page in the main content area.
|
||||
*/
|
||||
enum class SettingsCategory(val label: String) {
|
||||
GENERAL("General"),
|
||||
CHAT("Chat"),
|
||||
ACCOUNT("Account"),
|
||||
DATA("Data"),
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
||||
/**
|
||||
* Sidebar scaffold that switches between Conversations and Settings modes.
|
||||
* Uses [AnimatedContent] for a smooth horizontal slide transition.
|
||||
*
|
||||
* The Settings footer button now navigates directly to the tabbed settings
|
||||
* screen via [onSettingsClick], rather than switching sidebar mode.
|
||||
* The mode-switching infrastructure is preserved for potential future use.
|
||||
*/
|
||||
@Composable
|
||||
fun SidebarScaffold(
|
||||
viewModel: NavHostViewModel,
|
||||
onNewChat: () -> Unit,
|
||||
onConversationClick: (String) -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onSettingsCategorySelected: (SettingsCategory) -> Unit,
|
||||
onAgentsClick: () -> Unit,
|
||||
onFilesClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sidebarMode by viewModel.sidebarMode.collectAsStateWithLifecycle()
|
||||
val selectedCategory by viewModel.selectedSettingsCategory.collectAsStateWithLifecycle()
|
||||
|
||||
AnimatedContent(
|
||||
targetState = sidebarMode,
|
||||
modifier = modifier,
|
||||
transitionSpec = {
|
||||
if (targetState is SidebarMode.Settings) {
|
||||
// Slide in from right when switching to Settings
|
||||
slideInHorizontally { fullWidth -> fullWidth } togetherWith
|
||||
slideOutHorizontally { fullWidth -> -fullWidth }
|
||||
} else {
|
||||
// Slide in from left when switching back to Conversations
|
||||
slideInHorizontally { fullWidth -> -fullWidth } togetherWith
|
||||
slideOutHorizontally { fullWidth -> fullWidth }
|
||||
}
|
||||
},
|
||||
label = "SidebarModeTransition",
|
||||
) { mode ->
|
||||
when (mode) {
|
||||
is SidebarMode.Conversations -> {
|
||||
DrawerContent(
|
||||
viewModel = viewModel,
|
||||
onNewChat = onNewChat,
|
||||
onConversationClick = onConversationClick,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onAgentsClick = onAgentsClick,
|
||||
onFilesClick = onFilesClick,
|
||||
)
|
||||
}
|
||||
is SidebarMode.Settings -> {
|
||||
SettingsSidebarContent(
|
||||
selectedCategory = selectedCategory,
|
||||
onBackToConversations = {
|
||||
viewModel.setSidebarMode(SidebarMode.Conversations)
|
||||
},
|
||||
onCategorySelected = { category ->
|
||||
viewModel.selectSettingsCategory(category)
|
||||
onSettingsCategorySelected(category)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
|
|
@ -27,16 +26,17 @@ import androidx.compose.ui.layout.Layout
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.MainActivity
|
||||
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.Chat
|
||||
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
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
private val SidebarWidth = 320.dp
|
||||
|
||||
|
|
@ -47,10 +47,7 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
|
|||
fun TabletLayout(
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
deepLinkUri: Uri?,
|
||||
onDeepLinkConsumed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
shareNavigationTrigger: Int = 0,
|
||||
) {
|
||||
// Banner state only -- drawer state is collected inside DrawerContent itself
|
||||
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
||||
|
|
@ -74,33 +71,6 @@ fun TabletLayout(
|
|||
navHostViewModel.setTabletSidebarOpen(false)
|
||||
}
|
||||
|
||||
// Handle deep links
|
||||
LaunchedEffect(deepLinkUri) {
|
||||
deepLinkUri?.let { uri ->
|
||||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||
uri.lastPathSegment?.let { conversationId ->
|
||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||
navigator.navigateToChat(conversationId)
|
||||
} else {
|
||||
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
onDeepLinkConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle share intent
|
||||
LaunchedEffect(shareNavigationTrigger) {
|
||||
if (shareNavigationTrigger > 0) {
|
||||
val currentRoute = navigator.currentRoute
|
||||
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||
if (!isOnChatScreen) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync: when ViewModel state changes (e.g. hamburger button, back press),
|
||||
// animate the sidebar to match.
|
||||
LaunchedEffect(isSidebarOpen) {
|
||||
|
|
@ -273,7 +243,7 @@ private fun MainContent(
|
|||
MainNavDisplay(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
onOpenDrawer = onToggleDrawer,
|
||||
onMenuClick = onToggleDrawer,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import com.garfiec.librechat.feature.conversations.di.conversationsModule
|
|||
import com.garfiec.librechat.feature.files.di.filesModule
|
||||
import com.garfiec.librechat.feature.files.platform.FileReader
|
||||
import com.garfiec.librechat.feature.settings.di.settingsModule
|
||||
import com.garfiec.librechat.navigation.appModule
|
||||
import com.garfiec.librechat.shared.navigation.sharedAppModule
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.junit.Test
|
||||
|
|
@ -76,7 +76,7 @@ class KoinGraphVerificationTest {
|
|||
commonModule,
|
||||
networkModule,
|
||||
dataModule,
|
||||
appModule,
|
||||
sharedAppModule,
|
||||
authModule,
|
||||
chatModule,
|
||||
conversationsModule,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.garfiec.librechat.navigation
|
|||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.shared.navigation.Navigator
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.auth.navigation.Login
|
||||
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package com.garfiec.librechat.core.common.extensions
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
actual fun <T> firstBlocking(flow: Flow<T>, default: T): T =
|
||||
try {
|
||||
runBlocking { flow.first() }
|
||||
} catch (_: Exception) {
|
||||
default
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.garfiec.librechat.core.common.extensions
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Reads the first value from [flow] synchronously via `runBlocking`, returning
|
||||
* [default] if the flow is empty or the read fails for any reason.
|
||||
*
|
||||
* Used so that initial UI state (e.g. persisted sidebar open/close) is available
|
||||
* before first composition, avoiding visible flicker on cold start.
|
||||
*/
|
||||
expect fun <T> firstBlocking(flow: Flow<T>, default: T): T
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.garfiec.librechat.core.common.extensions
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
actual fun <T> firstBlocking(flow: Flow<T>, default: T): T = default
|
||||
|
|
@ -86,6 +86,7 @@ fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
|||
fun LibreChatNavHost(
|
||||
modifier: Modifier = Modifier,
|
||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||
content: (@Composable (Navigator, NavHostViewModel, Modifier) -> Unit)? = null,
|
||||
) {
|
||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -113,11 +114,15 @@ fun LibreChatNavHost(
|
|||
}
|
||||
}
|
||||
|
||||
PhoneLayout(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
modifier = modifier,
|
||||
)
|
||||
if (content != null) {
|
||||
content(navigator, navHostViewModel, modifier)
|
||||
} else {
|
||||
PhoneLayout(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
// Version mismatch warning dialog
|
||||
val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle()
|
||||
|
|
@ -165,8 +170,9 @@ private fun VersionMismatchDialog(
|
|||
)
|
||||
}
|
||||
|
||||
/** Default phone layout with modal drawer sidebar. Used by iOS directly and by Android as the non-tablet path. */
|
||||
@Composable
|
||||
private fun PhoneLayout(
|
||||
fun PhoneLayout(
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -228,88 +234,103 @@ private fun PhoneLayout(
|
|||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
NavDisplay(
|
||||
backStack = navigator.backStack,
|
||||
onBack = { navigator.goBack() },
|
||||
transitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
popTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
authEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onAuthComplete = {
|
||||
navHostViewModel.onAuthComplete()
|
||||
navigator.navigateToChat()
|
||||
},
|
||||
)
|
||||
chatEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onNavigateToChat = { navigator.navigateToChat(it) },
|
||||
onOpenDrawer = {
|
||||
scope.launch { drawerState.open() }
|
||||
},
|
||||
)
|
||||
conversationsEntries(
|
||||
onConversationClick = { navigator.navigateToChat(it) },
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
agentsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onStartChat = { _ ->
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
},
|
||||
)
|
||||
filesEntries(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
settingsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onLogout = {
|
||||
navHostViewModel.logout()
|
||||
navigator.navigateToAuth()
|
||||
},
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
)
|
||||
memoriesEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
mcpServersEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
},
|
||||
MainNavDisplay(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
onMenuClick = { scope.launch { drawerState.open() } },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Core NavDisplay with entry providers for all feature modules. Used by both PhoneLayout and Android's TabletLayout. */
|
||||
@Composable
|
||||
fun MainNavDisplay(
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
onMenuClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = navigator.backStack,
|
||||
onBack = { navigator.goBack() },
|
||||
modifier = modifier,
|
||||
transitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
popTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
authEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onAuthComplete = {
|
||||
navHostViewModel.onAuthComplete()
|
||||
navigator.navigateToChat()
|
||||
},
|
||||
)
|
||||
chatEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onNavigateToChat = { navigator.navigateToChat(it) },
|
||||
onOpenDrawer = onMenuClick,
|
||||
)
|
||||
conversationsEntries(
|
||||
onConversationClick = { navigator.navigateToChat(it) },
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
agentsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onStartChat = { _ ->
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
},
|
||||
)
|
||||
filesEntries(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
settingsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onLogout = {
|
||||
navHostViewModel.logout()
|
||||
navigator.navigateToAuth()
|
||||
},
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
)
|
||||
memoriesEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
mcpServersEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.first
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.garfiec.librechat.core.common.extensions.firstBlocking
|
||||
import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
|
@ -107,7 +108,7 @@ class NavHostViewModel(
|
|||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
initialValue = false, // Avoid runBlocking — not safe on iOS/Kotlin Native main thread
|
||||
firstBlocking(settingsDataStore.tabletSidebarOpen, false),
|
||||
)
|
||||
|
||||
val tabletSidebarGestureEnabled: StateFlow<Boolean> = settingsDataStore.tabletSidebarGestureEnabled
|
||||
|
|
|
|||
Loading…
Reference in a new issue