Merge pull request #12 from garfiec/feature/nav3-migration
Migrate to Navigation Compose 3 with type-safe routes
This commit is contained in:
commit
d8bdfa843b
63 changed files with 1112 additions and 1199 deletions
|
|
@ -4,18 +4,19 @@ Native mobile client for LibreChat (Android & iOS). Connects to existing LibreCh
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **UI**: Jetpack Compose + Compose Navigation
|
- **UI**: Jetpack Compose + Navigation Compose 3 (Nav 3)
|
||||||
- **DI**: Koin (KMP-ready)
|
- **DI**: Koin (KMP-ready)
|
||||||
- **Network**: Ktor Client (OkHttp engine)
|
- **Network**: Ktor Client (OkHttp engine)
|
||||||
- **Serialization**: Kotlinx Serialization
|
- **Serialization**: Kotlinx Serialization
|
||||||
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens)
|
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens)
|
||||||
- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26
|
- **Build**: Gradle 9.4.1, AGP 9.1.0, Kotlin 2.3.20, compileSdk 36, minSdk 26
|
||||||
|
|
||||||
## Module Layout
|
## Module Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
app/ → Single Activity, adaptive navigation (phone/tablet)
|
app/ → Single Activity, adaptive navigation (phone/tablet)
|
||||||
build-logic/ → 7 convention plugins for consistent Gradle config
|
shared/ → KMP shared framework (iOS entry point, shared navigation)
|
||||||
|
build-logic/ → 13 convention plugins for consistent Gradle config
|
||||||
core/common/ → Result type, dispatcher DI, coroutine scopes, extensions
|
core/common/ → Result type, dispatcher DI, coroutine scopes, extensions
|
||||||
core/model/ → @Serializable data classes (pure Kotlin, no Android deps)
|
core/model/ → @Serializable data classes (pure Kotlin, no Android deps)
|
||||||
core/network/ → Ktor client, 16 API services, SSE client, auth interceptor
|
core/network/ → Ktor client, 16 API services, SSE client, auth interceptor
|
||||||
|
|
@ -34,7 +35,7 @@ Each module has its own `CLAUDE.md` with specific guidance.
|
||||||
## Architecture Rules
|
## Architecture Rules
|
||||||
|
|
||||||
- Feature modules depend on `:core:*` only, never on each other
|
- Feature modules depend on `:core:*` only, never on each other
|
||||||
- Single Activity with Compose Navigation
|
- Single Activity with Nav 3 (`NavDisplay` + `NavBackStack<NavKey>` + `entryProvider`)
|
||||||
- Unidirectional data flow: UI → ViewModel → Repository → API/Room
|
- Unidirectional data flow: UI → ViewModel → Repository → API/Room
|
||||||
- Room is a read-through cache; server is source of truth
|
- Room is a read-through cache; server is source of truth
|
||||||
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)
|
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
# Known Issues
|
# Known Issues
|
||||||
|
|
||||||
## Tablet back navigation doesn't dismiss sidebar first
|
|
||||||
|
|
||||||
**Status**: Known issue, to be fixed later
|
|
||||||
|
|
||||||
**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen.
|
|
||||||
|
|
||||||
**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost.
|
|
||||||
|
|
||||||
**Workaround**: Tap the hamburger menu button to toggle the sidebar closed.
|
|
||||||
|
|
||||||
## Chat search: sub-message scroll precision
|
## Chat search: sub-message scroll precision
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ Single Activity architecture. `MainActivity` is the sole entry point.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`:
|
`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack<NavKey>` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`:
|
||||||
|
|
||||||
- **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.
|
- **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.
|
- **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar.
|
||||||
|
|
||||||
|
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
|
## Sidebar / Drawer Content
|
||||||
|
|
||||||
`DrawerContent` is the rich sidebar matching the web's left panel:
|
`DrawerContent` is the rich sidebar matching the web's left panel:
|
||||||
|
|
@ -22,8 +24,8 @@ Single Activity architecture. `MainActivity` is the sole entry point.
|
||||||
|
|
||||||
## Top-Level Destinations
|
## Top-Level Destinations
|
||||||
|
|
||||||
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings.
|
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 remain registered in the NavHost.
|
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
|
## Active Conversation Tracking
|
||||||
|
|
||||||
|
|
@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
|
||||||
## Auth & Session
|
## Auth & Session
|
||||||
|
|
||||||
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
|
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
|
||||||
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise
|
- Start destination is `NewChat` if logged in, `ServerUrl` otherwise
|
||||||
- `sessionExpired` flow triggers nav to auth graph with full backstack clear
|
- `sessionExpired` flow triggers nav to auth flow with full back stack clear
|
||||||
- Drawer gestures are hidden during auth flow
|
- Drawer gestures are hidden during auth flow
|
||||||
|
|
||||||
## Deep Linking
|
## Deep Linking
|
||||||
|
|
@ -66,6 +68,6 @@ It applies convention plugins: `librechat.mobile.application`, `librechat.mobile
|
||||||
- **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed
|
- **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
|
### Preset Navigation
|
||||||
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt`
|
- `PresetManager` route registered in `SettingsNavigation.kt`
|
||||||
- `PresetManagerScreen` accessible from Settings → Chat section
|
- `PresetManagerScreen` accessible from Settings → Chat section
|
||||||
- Navigation wired in both `LibreChatNavHost` and `TabletLayout`
|
- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ plugins {
|
||||||
id("librechat.mobile.application")
|
id("librechat.mobile.application")
|
||||||
id("librechat.mobile.compose")
|
id("librechat.mobile.compose")
|
||||||
id("librechat.mobile.koin")
|
id("librechat.mobile.koin")
|
||||||
|
id("librechat.kotlin.serialization")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -30,7 +31,8 @@ dependencies {
|
||||||
implementation(project(":feature:files"))
|
implementation(project(":feature:files"))
|
||||||
|
|
||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
implementation(libs.navigation.compose)
|
implementation(libs.navigation3.ui.kmp)
|
||||||
|
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
|
||||||
implementation(libs.koin.compose)
|
implementation(libs.koin.compose)
|
||||||
implementation(libs.koin.compose.viewmodel)
|
implementation(libs.koin.compose.viewmodel)
|
||||||
implementation(libs.koin.compose.viewmodel.navigation)
|
implementation(libs.koin.compose.viewmodel.navigation)
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Incremented each time a share intent arrives.
|
* Incremented each time a share intent arrives.
|
||||||
* The NavHost observes this and either:
|
* The NavDisplay observes this and either:
|
||||||
* - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or
|
* - Navigates to NewChat if the user is not on a chat screen, or
|
||||||
* - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen.
|
* - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen.
|
||||||
*/
|
*/
|
||||||
private var shareNavigationTrigger by mutableStateOf(0)
|
private var shareNavigationTrigger by mutableStateOf(0)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package com.garfiec.librechat.navigation
|
package com.garfiec.librechat.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
viewModelOf(::NavHostViewModel)
|
viewModelOf(::NavHostViewModel)
|
||||||
|
single<SerializersModule>(named("navigation")) { navigationSerializersModule }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ private val ActiveIndicatorShape = RoundedCornerShape(2.dp)
|
||||||
/**
|
/**
|
||||||
* Stateful DrawerContent that collects its own state from the ViewModel.
|
* Stateful DrawerContent that collects its own state from the ViewModel.
|
||||||
* State changes only recompose inside this composable — not the parent
|
* State changes only recompose inside this composable — not the parent
|
||||||
* PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content.
|
* PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DrawerContent(
|
fun DrawerContent(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.garfiec.librechat.navigation
|
package com.garfiec.librechat.navigation
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
|
@ -9,7 +8,9 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideOut
|
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.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -29,45 +30,48 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.MainActivity
|
||||||
import com.garfiec.librechat.R
|
import com.garfiec.librechat.R
|
||||||
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
||||||
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
|
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
|
||||||
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
|
||||||
import com.garfiec.librechat.feature.auth.navigation.authGraph
|
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
import com.garfiec.librechat.feature.auth.navigation.authEntries
|
||||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE
|
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||||
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE
|
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||||
import com.garfiec.librechat.feature.chat.navigation.chatGraph
|
import com.garfiec.librechat.feature.chat.navigation.chatEntries
|
||||||
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
|
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
|
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
|
||||||
import com.garfiec.librechat.feature.files.navigation.filesGraph
|
import com.garfiec.librechat.feature.files.navigation.Files
|
||||||
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE
|
import com.garfiec.librechat.feature.files.navigation.filesEntries
|
||||||
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsData
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
|
||||||
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
|
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
|
||||||
|
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
/** Maps a [SettingsCategory] to its corresponding navigation route. */
|
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
|
||||||
fun SettingsCategory.toRoute(): String = when (this) {
|
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
||||||
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE
|
SettingsCategory.GENERAL -> SettingsGeneral
|
||||||
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE
|
SettingsCategory.CHAT -> SettingsChat
|
||||||
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE
|
SettingsCategory.ACCOUNT -> SettingsAccount
|
||||||
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
|
SettingsCategory.DATA -> SettingsData
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -79,47 +83,40 @@ fun LibreChatNavHost(
|
||||||
shareNavigationTrigger: Int = 0,
|
shareNavigationTrigger: Int = 0,
|
||||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentDestination = navBackStackEntry?.destination
|
|
||||||
|
|
||||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
|
// Stable start key — auth redirect handled via LaunchedEffect below.
|
||||||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
|
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
|
// Track active conversation from nav back stack
|
||||||
LaunchedEffect(navBackStackEntry) {
|
LaunchedEffect(navigator.currentRoute) {
|
||||||
val route = navBackStackEntry?.destination?.route
|
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
|
||||||
val conversationId = if (route == CHAT_ROUTE) {
|
|
||||||
navBackStackEntry?.arguments?.getString("conversationId")
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
navHostViewModel.setActiveConversation(conversationId)
|
navHostViewModel.setActiveConversation(conversationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle session expiry
|
// Handle session expiry
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
navHostViewModel.sessionExpired.collect {
|
navHostViewModel.sessionExpired.collect {
|
||||||
navController.navigate(AUTH_GRAPH_ROUTE) {
|
navigator.navigateToAuth()
|
||||||
popUpTo(0) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
|
|
||||||
|
|
||||||
val isTablet = windowSizeClass?.widthSizeClass?.let {
|
val isTablet = windowSizeClass?.widthSizeClass?.let {
|
||||||
it >= WindowWidthSizeClass.Medium
|
it >= WindowWidthSizeClass.Medium
|
||||||
} ?: false
|
} ?: false
|
||||||
|
|
||||||
if (isTablet) {
|
if (isTablet) {
|
||||||
TabletLayout(
|
TabletLayout(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
navHostViewModel = navHostViewModel,
|
navHostViewModel = navHostViewModel,
|
||||||
startDestination = startDestination,
|
|
||||||
isInAuthFlow = isInAuthFlow,
|
|
||||||
deepLinkUri = deepLinkUri,
|
deepLinkUri = deepLinkUri,
|
||||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||||
shareNavigationTrigger = shareNavigationTrigger,
|
shareNavigationTrigger = shareNavigationTrigger,
|
||||||
|
|
@ -127,10 +124,8 @@ fun LibreChatNavHost(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PhoneLayout(
|
PhoneLayout(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
navHostViewModel = navHostViewModel,
|
navHostViewModel = navHostViewModel,
|
||||||
startDestination = startDestination,
|
|
||||||
isInAuthFlow = isInAuthFlow,
|
|
||||||
deepLinkUri = deepLinkUri,
|
deepLinkUri = deepLinkUri,
|
||||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||||
shareNavigationTrigger = shareNavigationTrigger,
|
shareNavigationTrigger = shareNavigationTrigger,
|
||||||
|
|
@ -191,10 +186,8 @@ private fun VersionMismatchDialog(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneLayout(
|
private fun PhoneLayout(
|
||||||
navController: androidx.navigation.NavHostController,
|
navigator: Navigator,
|
||||||
navHostViewModel: NavHostViewModel,
|
navHostViewModel: NavHostViewModel,
|
||||||
startDestination: String,
|
|
||||||
isInAuthFlow: Boolean,
|
|
||||||
deepLinkUri: Uri?,
|
deepLinkUri: Uri?,
|
||||||
onDeepLinkConsumed: () -> Unit,
|
onDeepLinkConsumed: () -> Unit,
|
||||||
shareNavigationTrigger: Int,
|
shareNavigationTrigger: Int,
|
||||||
|
|
@ -202,7 +195,6 @@ private fun PhoneLayout(
|
||||||
) {
|
) {
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Banner state only -- drawer state is collected inside DrawerContent itself
|
|
||||||
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
||||||
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
|
@ -212,7 +204,7 @@ private fun PhoneLayout(
|
||||||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||||
uri.lastPathSegment?.let { conversationId ->
|
uri.lastPathSegment?.let { conversationId ->
|
||||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||||
navController.navigateToChat(conversationId)
|
navigator.navigateToChat(conversationId)
|
||||||
} else {
|
} else {
|
||||||
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
||||||
}
|
}
|
||||||
|
|
@ -222,21 +214,14 @@ private fun PhoneLayout(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle share intent: if already on a chat screen, let the active ChatViewModel
|
// Handle share intent
|
||||||
// consume the shared content reactively. Only navigate to new chat when on a
|
|
||||||
// non-chat screen (e.g. settings, agents, files).
|
|
||||||
LaunchedEffect(shareNavigationTrigger) {
|
LaunchedEffect(shareNavigationTrigger) {
|
||||||
if (shareNavigationTrigger > 0) {
|
if (shareNavigationTrigger > 0) {
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
val currentRoute = navigator.currentRoute
|
||||||
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
|
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||||
if (!isOnChatScreen) {
|
if (!isOnChatScreen) {
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
navigator.navigateToTopLevel(NewChat)
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If already on a chat screen, SharedIntentConsumer.shareAvailable
|
|
||||||
// will notify the active ChatViewModel to consume the content.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,57 +234,42 @@ private fun PhoneLayout(
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
gesturesEnabled = !isInAuthFlow,
|
gesturesEnabled = !navigator.isInAuthFlow,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
SidebarScaffold(
|
SidebarScaffold(
|
||||||
viewModel = navHostViewModel,
|
viewModel = navHostViewModel,
|
||||||
onNewChat = {
|
onNewChat = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
// Skip navigation if already on the new chat screen
|
if (navigator.currentRoute !is NewChat) {
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
navigator.navigateToTopLevel(NewChat)
|
||||||
if (currentRoute != NEW_CHAT_ROUTE) {
|
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConversationClick = { conversationId ->
|
onConversationClick = { conversationId ->
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigateToChat(conversationId)
|
navigator.navigateToChat(conversationId)
|
||||||
},
|
},
|
||||||
onSettingsClick = {
|
onSettingsClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
navigator.navigate(SettingsTabbed)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSettingsCategorySelected = { category ->
|
onSettingsCategorySelected = { category ->
|
||||||
// Navigate to the settings sub-page but keep the drawer open
|
navigator.navigate(category.toRoute())
|
||||||
navController.navigate(category.toRoute()) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onAgentsClick = {
|
onAgentsClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigate(TopLevelDestination.AGENTS.route) {
|
navigator.navigate(AgentMarketplace)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onFilesClick = {
|
onFilesClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigate(TopLevelDestination.FILES.route) {
|
navigator.navigate(Files)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// No bottom bar -- drawer is the primary navigation
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
if (!isInAuthFlow && banners.isNotEmpty()) {
|
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
|
||||||
BannerDisplay(
|
BannerDisplay(
|
||||||
banners = banners,
|
banners = banners,
|
||||||
dismissedIds = dismissedBannerIds,
|
dismissedIds = dismissedBannerIds,
|
||||||
|
|
@ -307,122 +277,100 @@ private fun PhoneLayout(
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
NavHost(
|
MainNavDisplay(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
startDestination = startDestination,
|
navHostViewModel = navHostViewModel,
|
||||||
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
enterTransition = {
|
)
|
||||||
slideIntoContainer(
|
|
||||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(150))
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
fadeIn(
|
|
||||||
initialAlpha = 0.5f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
) + scaleIn(
|
|
||||||
initialScale = 0.92f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOut(
|
|
||||||
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
|
||||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
|
||||||
) + scaleOut(
|
|
||||||
targetScale = 0.92f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
authGraph(
|
|
||||||
navController = navController,
|
|
||||||
onAuthComplete = {
|
|
||||||
navHostViewModel.onAuthComplete()
|
|
||||||
navController.navigate(CHAT_GRAPH_ROUTE) {
|
|
||||||
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
chatGraph(
|
|
||||||
navController = navController,
|
|
||||||
onOpenDrawer = {
|
|
||||||
scope.launch { drawerState.open() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
conversationsGraph(
|
|
||||||
onConversationClick = { conversationId ->
|
|
||||||
navController.navigateToChat(conversationId)
|
|
||||||
},
|
|
||||||
onNavigateToArchived = {
|
|
||||||
navController.navigate("conversations/archived")
|
|
||||||
},
|
|
||||||
onNavigateBackFromArchived = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
agentsGraph(
|
|
||||||
onAgentClick = { agentId ->
|
|
||||||
navController.navigate("agents/$agentId")
|
|
||||||
},
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onStartChat = { agentId ->
|
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreateAgent = {
|
|
||||||
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
|
|
||||||
},
|
|
||||||
onEditAgent = { agentId ->
|
|
||||||
navController.navigate("agents/editor/$agentId")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
filesGraph()
|
|
||||||
settingsGraph(
|
|
||||||
onLogout = {
|
|
||||||
navHostViewModel.logout()
|
|
||||||
navController.navigate(AUTH_GRAPH_ROUTE) {
|
|
||||||
popUpTo(0) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
|
||||||
onNavigateToArchived = {
|
|
||||||
navController.navigate("conversations/archived") {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateToSharedLinks = {
|
|
||||||
navController.navigate(SHARED_LINKS_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromSharedLinks = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onNavigateToPresets = {
|
|
||||||
navController.navigate(PRESET_MANAGER_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromPresets = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onNavigateToApiKeys = {
|
|
||||||
navController.navigate(API_KEYS_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromApiKeys = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,18 +2,9 @@ package com.garfiec.librechat.navigation
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
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.slideOut
|
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -34,29 +25,16 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import com.garfiec.librechat.MainActivity
|
import com.garfiec.librechat.MainActivity
|
||||||
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
||||||
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
|
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||||
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||||
import com.garfiec.librechat.feature.auth.navigation.authGraph
|
import com.garfiec.librechat.feature.files.navigation.Files
|
||||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE
|
|
||||||
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE
|
|
||||||
import com.garfiec.librechat.feature.chat.navigation.chatGraph
|
|
||||||
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
|
|
||||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
|
|
||||||
import com.garfiec.librechat.feature.files.navigation.filesGraph
|
|
||||||
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE
|
|
||||||
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE
|
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE
|
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE
|
|
||||||
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
|
|
@ -67,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TabletLayout(
|
fun TabletLayout(
|
||||||
navController: NavHostController,
|
navigator: Navigator,
|
||||||
navHostViewModel: NavHostViewModel,
|
navHostViewModel: NavHostViewModel,
|
||||||
startDestination: String,
|
|
||||||
isInAuthFlow: Boolean,
|
|
||||||
deepLinkUri: Uri?,
|
deepLinkUri: Uri?,
|
||||||
onDeepLinkConsumed: () -> Unit,
|
onDeepLinkConsumed: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -81,8 +57,6 @@ fun TabletLayout(
|
||||||
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel.
|
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel.
|
||||||
// The ViewModel's StateFlow initial value is read synchronously from DataStore,
|
|
||||||
// so the first composition already has the correct persisted state (no flicker).
|
|
||||||
val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
|
val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Whether swipe gesture is enabled (from settings)
|
// Whether swipe gesture is enabled (from settings)
|
||||||
|
|
@ -92,7 +66,6 @@ fun TabletLayout(
|
||||||
val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
|
val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
|
||||||
|
|
||||||
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open.
|
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open.
|
||||||
// During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx.
|
|
||||||
val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
|
val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -107,7 +80,7 @@ fun TabletLayout(
|
||||||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||||
uri.lastPathSegment?.let { conversationId ->
|
uri.lastPathSegment?.let { conversationId ->
|
||||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||||
navController.navigateToChat(conversationId)
|
navigator.navigateToChat(conversationId)
|
||||||
} else {
|
} else {
|
||||||
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
||||||
}
|
}
|
||||||
|
|
@ -117,18 +90,13 @@ fun TabletLayout(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle share intent: if already on a chat screen, let the active ChatViewModel
|
// Handle share intent
|
||||||
// consume the shared content reactively. Only navigate to new chat when on a
|
|
||||||
// non-chat screen (e.g. settings, agents, files).
|
|
||||||
LaunchedEffect(shareNavigationTrigger) {
|
LaunchedEffect(shareNavigationTrigger) {
|
||||||
if (shareNavigationTrigger > 0) {
|
if (shareNavigationTrigger > 0) {
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
val currentRoute = navigator.currentRoute
|
||||||
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
|
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||||
if (!isOnChatScreen) {
|
if (!isOnChatScreen) {
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
navigator.navigateToTopLevel(NewChat)
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +116,7 @@ fun TabletLayout(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isInAuthFlow) {
|
if (!navigator.isInAuthFlow) {
|
||||||
val swipeModifier = if (gestureEnabled) {
|
val swipeModifier = if (gestureEnabled) {
|
||||||
Modifier.pointerInput(Unit) {
|
Modifier.pointerInput(Unit) {
|
||||||
val velocityTracker = VelocityTracker()
|
val velocityTracker = VelocityTracker()
|
||||||
|
|
@ -159,7 +127,6 @@ fun TabletLayout(
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
val velocity = velocityTracker.calculateVelocity().x
|
val velocity = velocityTracker.calculateVelocity().x
|
||||||
val currentOffset = sidebarOffset.value
|
val currentOffset = sidebarOffset.value
|
||||||
// Decide target: fling velocity wins, otherwise use 50% threshold
|
|
||||||
val shouldOpen = when {
|
val shouldOpen = when {
|
||||||
velocity > FLING_VELOCITY_THRESHOLD -> true
|
velocity > FLING_VELOCITY_THRESHOLD -> true
|
||||||
velocity < -FLING_VELOCITY_THRESHOLD -> false
|
velocity < -FLING_VELOCITY_THRESHOLD -> false
|
||||||
|
|
@ -177,7 +144,6 @@ fun TabletLayout(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
// Snap back to the current committed state
|
|
||||||
val target = if (isSidebarOpen) sidebarWidthPx else 0f
|
val target = if (isSidebarOpen) sidebarWidthPx else 0f
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sidebarOffset.animateTo(
|
sidebarOffset.animateTo(
|
||||||
|
|
@ -214,37 +180,24 @@ fun TabletLayout(
|
||||||
SidebarScaffold(
|
SidebarScaffold(
|
||||||
viewModel = navHostViewModel,
|
viewModel = navHostViewModel,
|
||||||
onNewChat = {
|
onNewChat = {
|
||||||
// Skip navigation if already on the new chat screen
|
if (navigator.currentRoute !is NewChat) {
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
navigator.navigateToTopLevel(NewChat)
|
||||||
if (currentRoute != NEW_CHAT_ROUTE) {
|
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConversationClick = { conversationId ->
|
onConversationClick = { conversationId ->
|
||||||
navController.navigateToChat(conversationId)
|
navigator.navigateToChat(conversationId)
|
||||||
},
|
},
|
||||||
onSettingsClick = {
|
onSettingsClick = {
|
||||||
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
navigator.navigate(SettingsTabbed)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSettingsCategorySelected = { category ->
|
onSettingsCategorySelected = { category ->
|
||||||
navController.navigate(category.toRoute()) {
|
navigator.navigate(category.toRoute())
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onAgentsClick = {
|
onAgentsClick = {
|
||||||
navController.navigate(TopLevelDestination.AGENTS.route) {
|
navigator.navigate(AgentMarketplace)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onFilesClick = {
|
onFilesClick = {
|
||||||
navController.navigate(TopLevelDestination.FILES.route) {
|
navigator.navigate(Files)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(SidebarWidth)
|
.width(SidebarWidth)
|
||||||
|
|
@ -256,9 +209,8 @@ fun TabletLayout(
|
||||||
}
|
}
|
||||||
// Slot 1: Main content -- resizes to fill remaining space
|
// Slot 1: Main content -- resizes to fill remaining space
|
||||||
MainContent(
|
MainContent(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
navHostViewModel = navHostViewModel,
|
navHostViewModel = navHostViewModel,
|
||||||
startDestination = startDestination,
|
|
||||||
isInAuthFlow = false,
|
isInAuthFlow = false,
|
||||||
banners = banners,
|
banners = banners,
|
||||||
dismissedBannerIds = dismissedBannerIds,
|
dismissedBannerIds = dismissedBannerIds,
|
||||||
|
|
@ -272,29 +224,24 @@ fun TabletLayout(
|
||||||
val sidebarPx = SidebarWidth.roundToPx()
|
val sidebarPx = SidebarWidth.roundToPx()
|
||||||
val animatedPx = sidebarOffset.value.toInt()
|
val animatedPx = sidebarOffset.value.toInt()
|
||||||
|
|
||||||
// Sidebar: always measured at full 320dp width
|
|
||||||
val sidebarPlaceable = measurables[0].measure(
|
val sidebarPlaceable = measurables[0].measure(
|
||||||
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx),
|
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx),
|
||||||
)
|
)
|
||||||
// Main content: resizes to fill screen minus the visible sidebar portion
|
|
||||||
val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
|
val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
|
||||||
val mainPlaceable = measurables[1].measure(
|
val mainPlaceable = measurables[1].measure(
|
||||||
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
|
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
|
||||||
)
|
)
|
||||||
|
|
||||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||||
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
|
|
||||||
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
|
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
|
||||||
// Main content starts right after the visible sidebar portion
|
|
||||||
mainPlaceable.placeRelative(animatedPx, 0)
|
mainPlaceable.placeRelative(animatedPx, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Auth flow -- no sidebar, just main content
|
// Auth flow -- no sidebar, just main content
|
||||||
MainContent(
|
MainContent(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
navHostViewModel = navHostViewModel,
|
navHostViewModel = navHostViewModel,
|
||||||
startDestination = startDestination,
|
|
||||||
isInAuthFlow = true,
|
isInAuthFlow = true,
|
||||||
banners = banners,
|
banners = banners,
|
||||||
dismissedBannerIds = dismissedBannerIds,
|
dismissedBannerIds = dismissedBannerIds,
|
||||||
|
|
@ -306,9 +253,8 @@ fun TabletLayout(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainContent(
|
private fun MainContent(
|
||||||
navController: NavHostController,
|
navigator: Navigator,
|
||||||
navHostViewModel: NavHostViewModel,
|
navHostViewModel: NavHostViewModel,
|
||||||
startDestination: String,
|
|
||||||
isInAuthFlow: Boolean,
|
isInAuthFlow: Boolean,
|
||||||
banners: List<com.garfiec.librechat.core.model.Banner>,
|
banners: List<com.garfiec.librechat.core.model.Banner>,
|
||||||
dismissedBannerIds: Set<String>,
|
dismissedBannerIds: Set<String>,
|
||||||
|
|
@ -324,119 +270,11 @@ private fun MainContent(
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
NavHost(
|
MainNavDisplay(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
startDestination = startDestination,
|
navHostViewModel = navHostViewModel,
|
||||||
|
onOpenDrawer = onToggleDrawer,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
enterTransition = {
|
)
|
||||||
slideIntoContainer(
|
|
||||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(150))
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
fadeIn(
|
|
||||||
initialAlpha = 0.5f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
) + scaleIn(
|
|
||||||
initialScale = 0.92f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOut(
|
|
||||||
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
|
||||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
|
||||||
) + scaleOut(
|
|
||||||
targetScale = 0.92f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
authGraph(
|
|
||||||
navController = navController,
|
|
||||||
onAuthComplete = {
|
|
||||||
navHostViewModel.onAuthComplete()
|
|
||||||
navController.navigate(CHAT_GRAPH_ROUTE) {
|
|
||||||
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
chatGraph(
|
|
||||||
navController = navController,
|
|
||||||
onOpenDrawer = onToggleDrawer,
|
|
||||||
)
|
|
||||||
conversationsGraph(
|
|
||||||
onConversationClick = { conversationId ->
|
|
||||||
navController.navigateToChat(conversationId)
|
|
||||||
},
|
|
||||||
onNavigateToArchived = {
|
|
||||||
navController.navigate("conversations/archived")
|
|
||||||
},
|
|
||||||
onNavigateBackFromArchived = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
agentsGraph(
|
|
||||||
onAgentClick = { agentId ->
|
|
||||||
navController.navigate("agents/$agentId")
|
|
||||||
},
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onStartChat = { agentId ->
|
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreateAgent = {
|
|
||||||
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
|
|
||||||
},
|
|
||||||
onEditAgent = { agentId ->
|
|
||||||
navController.navigate("agents/editor/$agentId")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
filesGraph()
|
|
||||||
settingsGraph(
|
|
||||||
onLogout = {
|
|
||||||
navHostViewModel.logout()
|
|
||||||
navController.navigate(AUTH_GRAPH_ROUTE) {
|
|
||||||
popUpTo(0) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
|
||||||
onNavigateToArchived = {
|
|
||||||
navController.navigate("conversations/archived") {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateToSharedLinks = {
|
|
||||||
navController.navigate(SHARED_LINKS_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromSharedLinks = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onNavigateToPresets = {
|
|
||||||
navController.navigate(PRESET_MANAGER_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromPresets = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onNavigateToApiKeys = {
|
|
||||||
navController.navigate(API_KEYS_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromApiKeys = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package com.garfiec.librechat.navigation
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Chat
|
|
||||||
import androidx.compose.material.icons.filled.Folder
|
|
||||||
import androidx.compose.material.icons.filled.Forum
|
|
||||||
import androidx.compose.material.icons.filled.SmartToy
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destinations accessible from the navigation drawer footer.
|
|
||||||
* The primary navigation is now sidebar-first (drawer with conversation list),
|
|
||||||
* matching the web frontend pattern. Bottom nav has been removed entirely.
|
|
||||||
*
|
|
||||||
* Conversations are integrated into the drawer body (not a separate destination).
|
|
||||||
* Agents and Files are accessible via drawer footer links.
|
|
||||||
* Settings are accessed through the sidebar settings nav menu (see [SettingsCategory]).
|
|
||||||
*/
|
|
||||||
enum class TopLevelDestination(
|
|
||||||
val route: String,
|
|
||||||
val icon: ImageVector,
|
|
||||||
val label: String,
|
|
||||||
) {
|
|
||||||
CHAT("chat_graph", Icons.Default.Chat, "Chat"),
|
|
||||||
CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"),
|
|
||||||
AGENTS("agents", Icons.Default.SmartToy, "Agents"),
|
|
||||||
FILES("files", Icons.Default.Folder, "Files"),
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package com.garfiec.librechat.navigation
|
||||||
|
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||||
|
import com.garfiec.librechat.feature.auth.navigation.Login
|
||||||
|
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||||
|
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||||
|
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||||
|
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class NavigatorTest {
|
||||||
|
|
||||||
|
private fun createNavigator(vararg keys: NavKey): Navigator {
|
||||||
|
return Navigator(NavBackStack(*keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `currentRoute returns last entry`() {
|
||||||
|
val navigator = createNavigator(NewChat)
|
||||||
|
assertEquals(NewChat, navigator.currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `currentRoute returns null for empty stack`() {
|
||||||
|
val navigator = createNavigator()
|
||||||
|
assertNull(navigator.currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isInAuthFlow returns true for auth routes`() {
|
||||||
|
val navigator = createNavigator(ServerUrl)
|
||||||
|
assertTrue(navigator.isInAuthFlow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isInAuthFlow returns true for nested auth routes`() {
|
||||||
|
val navigator = createNavigator(ServerUrl, Login)
|
||||||
|
assertTrue(navigator.isInAuthFlow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isInAuthFlow returns false for non-auth routes`() {
|
||||||
|
val navigator = createNavigator(NewChat)
|
||||||
|
assertFalse(navigator.isInAuthFlow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate adds route to back stack`() {
|
||||||
|
val navigator = createNavigator(NewChat)
|
||||||
|
navigator.navigate(SettingsTabbed)
|
||||||
|
assertEquals(listOf(NewChat, SettingsTabbed), navigator.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `goBack removes top entry`() {
|
||||||
|
val navigator = createNavigator(NewChat, SettingsTabbed)
|
||||||
|
navigator.goBack()
|
||||||
|
assertEquals(listOf(NewChat), navigator.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `goBack on empty stack does not crash`() {
|
||||||
|
val navigator = createNavigator()
|
||||||
|
navigator.goBack()
|
||||||
|
assertTrue(navigator.backStack.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToChat with conversationId adds Chat route`() {
|
||||||
|
val navigator = createNavigator(NewChat)
|
||||||
|
navigator.navigateToChat("conv-123")
|
||||||
|
assertEquals(
|
||||||
|
listOf(NewChat, Chat(conversationId = "conv-123")),
|
||||||
|
navigator.backStack.toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToChat replaces current chat`() {
|
||||||
|
val navigator = createNavigator(NewChat, Chat("conv-1"))
|
||||||
|
navigator.navigateToChat("conv-2")
|
||||||
|
assertEquals(
|
||||||
|
listOf(NewChat, Chat(conversationId = "conv-2")),
|
||||||
|
navigator.backStack.toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToChat does not replace non-chat route`() {
|
||||||
|
val navigator = createNavigator(NewChat, SettingsTabbed)
|
||||||
|
navigator.navigateToChat("conv-1")
|
||||||
|
assertEquals(
|
||||||
|
listOf(NewChat, SettingsTabbed, Chat(conversationId = "conv-1")),
|
||||||
|
navigator.backStack.toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToTopLevel pops to root and replaces`() {
|
||||||
|
val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed)
|
||||||
|
navigator.navigateToTopLevel(AgentMarketplace)
|
||||||
|
assertEquals(listOf(AgentMarketplace), navigator.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToTopLevel with same route does not duplicate`() {
|
||||||
|
val navigator = createNavigator(NewChat)
|
||||||
|
navigator.navigateToTopLevel(NewChat)
|
||||||
|
assertEquals(listOf(NewChat), navigator.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToTopLevel replaces different root`() {
|
||||||
|
val navigator = createNavigator(NewChat)
|
||||||
|
navigator.navigateToTopLevel(AgentMarketplace)
|
||||||
|
assertEquals(listOf(AgentMarketplace), navigator.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToAuth clears stack and adds ServerUrl`() {
|
||||||
|
val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed)
|
||||||
|
navigator.navigateToAuth()
|
||||||
|
assertEquals(listOf(ServerUrl), navigator.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateToChat no-arg clears stack and adds NewChat`() {
|
||||||
|
val navigator = createNavigator(ServerUrl, Login)
|
||||||
|
navigator.navigateToChat()
|
||||||
|
assertEquals(listOf(NewChat), navigator.backStack.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,16 +2,31 @@
|
||||||
|
|
||||||
Convention plugins that apply consistent Gradle configuration across all modules.
|
Convention plugins that apply consistent Gradle configuration across all modules.
|
||||||
|
|
||||||
## Convention Plugins (7 total)
|
## Convention Plugins (13 total)
|
||||||
|
|
||||||
|
### Android
|
||||||
| Plugin ID | Class | What it does |
|
| Plugin ID | Class | What it does |
|
||||||
|-----------|-------|-------------|
|
|-----------|-------|-------------|
|
||||||
| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 |
|
| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 36, minSdk 26, JDK 17 |
|
||||||
| `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
|
| `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
|
||||||
| `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
|
| `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
|
||||||
| `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies |
|
| `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies |
|
||||||
| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization |
|
| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization + Nav 3 |
|
||||||
| `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
|
| `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
|
||||||
|
| `librechat.mobile.detekt` | `AndroidDetektConventionPlugin` | Detekt static analysis |
|
||||||
|
|
||||||
|
### KMP
|
||||||
|
| Plugin ID | Class | What it does |
|
||||||
|
|-----------|-------|-------------|
|
||||||
|
| `librechat.kmp.library` | `KmpLibraryConventionPlugin` | KMP library setup (Android + iOS targets) |
|
||||||
|
| `librechat.kmp.compose` | `KmpComposeConventionPlugin` | Compose Multiplatform + Material 3 |
|
||||||
|
| `librechat.kmp.koin` | `KmpKoinConventionPlugin` | Koin multiplatform dependencies |
|
||||||
|
| `librechat.kmp.feature` | `KmpFeatureConventionPlugin` | Auto-applies: kmp library + compose + koin + serialization + Nav 3 |
|
||||||
|
| `librechat.kmp.room` | `KmpRoomConventionPlugin` | Room multiplatform + KSP |
|
||||||
|
|
||||||
|
### Shared
|
||||||
|
| Plugin ID | Class | What it does |
|
||||||
|
|-----------|-------|-------------|
|
||||||
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
|
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
|
||||||
|
|
||||||
## Critical: apply() Signature
|
## Critical: apply() Signature
|
||||||
|
|
@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs.
|
||||||
|
|
||||||
## Feature Module Convention
|
## Feature Module Convention
|
||||||
|
|
||||||
Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose,
|
Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose,
|
||||||
koin, and serialization. They only need to add feature-specific dependencies.
|
koin, serialization, and Nav 3. They only need to add feature-specific dependencies.
|
||||||
Feature modules depend on `:core:*` only, never on other feature modules.
|
Feature modules depend on `:core:*` only, never on other feature modules.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
|
||||||
add("implementation", libs.findLibrary("koin-compose").get())
|
add("implementation", libs.findLibrary("koin-compose").get())
|
||||||
add("implementation", libs.findLibrary("koin-compose-viewmodel").get())
|
add("implementation", libs.findLibrary("koin-compose-viewmodel").get())
|
||||||
add("implementation", libs.findLibrary("koin-compose-viewmodel-navigation").get())
|
add("implementation", libs.findLibrary("koin-compose-viewmodel-navigation").get())
|
||||||
add("implementation", libs.findLibrary("navigation-compose").get())
|
add("implementation", libs.findLibrary("navigation3-ui-kmp").get())
|
||||||
|
add("implementation", libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get())
|
||||||
add("implementation", libs.findBundle("lifecycle").get())
|
add("implementation", libs.findBundle("lifecycle").get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
|
||||||
implementation(libs.findLibrary("koin-compose").get())
|
implementation(libs.findLibrary("koin-compose").get())
|
||||||
implementation(libs.findLibrary("koin-compose-viewmodel").get())
|
implementation(libs.findLibrary("koin-compose-viewmodel").get())
|
||||||
implementation(libs.findLibrary("koin-compose-viewmodel-navigation").get())
|
implementation(libs.findLibrary("koin-compose-viewmodel-navigation").get())
|
||||||
implementation(libs.findLibrary("navigation-compose-kmp").get())
|
implementation(libs.findLibrary("navigation3-ui-kmp").get())
|
||||||
implementation(libs.findLibrary("lifecycle-runtime-compose-kmp").get())
|
implementation(libs.findLibrary("lifecycle-runtime-compose-kmp").get())
|
||||||
implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get())
|
implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get())
|
||||||
|
implementation(libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ These rules apply to ALL composables across feature modules, not just core:ui.
|
||||||
|
|
||||||
3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types.
|
3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types.
|
||||||
|
|
||||||
4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavHost. State changes should only recompose the composable that reads them.
|
4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavDisplay. State changes should only recompose the composable that reads them.
|
||||||
|
|
||||||
5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame).
|
5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
package com.garfiec.librechat.core.ui.components
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.view.RoundedCorner
|
|
||||||
import androidx.compose.animation.EnterExitState
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.Transition
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
private const val FALLBACK_CORNER_RADIUS_DP = 24f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps screen content to apply rounded corners during navigation transitions.
|
|
||||||
* Pass the [transition] from AnimatedVisibilityScope inside a composable() block.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
actual fun ScreenTransitionWrapper(
|
|
||||||
transition: Transition<EnterExitState>,
|
|
||||||
modifier: Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val screenCornerRadiusDp = rememberScreenCornerRadiusDp()
|
|
||||||
|
|
||||||
val cornerRadius by transition.animateFloat(
|
|
||||||
transitionSpec = { tween(300, easing = LinearEasing) },
|
|
||||||
label = "screenCorner",
|
|
||||||
) { state ->
|
|
||||||
when (state) {
|
|
||||||
EnterExitState.PreEnter -> screenCornerRadiusDp
|
|
||||||
EnterExitState.Visible -> 0f
|
|
||||||
EnterExitState.PostExit -> screenCornerRadiusDp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clip(RoundedCornerShape(cornerRadius.dp)),
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberScreenCornerRadiusDp(): Float {
|
|
||||||
val view = LocalView.current
|
|
||||||
val density = LocalDensity.current
|
|
||||||
return remember(view, density) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
val insets = view.rootWindowInsets
|
|
||||||
val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)
|
|
||||||
if (topLeft != null && topLeft.radius > 0) {
|
|
||||||
with(density) { topLeft.radius.toDp().value }
|
|
||||||
} else {
|
|
||||||
FALLBACK_CORNER_RADIUS_DP
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
FALLBACK_CORNER_RADIUS_DP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package com.garfiec.librechat.core.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.animation.EnterExitState
|
|
||||||
import androidx.compose.animation.core.Transition
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps screen content to apply rounded corners during navigation transitions.
|
|
||||||
* On Android, reads the device screen corner radius for a polished effect.
|
|
||||||
* On iOS, renders content directly (no transition animation needed).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
expect fun ScreenTransitionWrapper(
|
|
||||||
transition: Transition<EnterExitState>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
)
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
package com.garfiec.librechat.core.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.animation.EnterExitState
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.Transition
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun ScreenTransitionWrapper(
|
|
||||||
transition: Transition<EnterExitState>,
|
|
||||||
modifier: Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val alpha by transition.animateFloat(
|
|
||||||
transitionSpec = { tween(300, easing = LinearEasing) },
|
|
||||||
label = "screenAlpha",
|
|
||||||
) { state ->
|
|
||||||
when (state) {
|
|
||||||
EnterExitState.PreEnter -> 0f
|
|
||||||
EnterExitState.Visible -> 1f
|
|
||||||
EnterExitState.PostExit -> 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer { this.alpha = alpha },
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
- **AgentDetailScreen** -- full agent info with "Start Chat" action
|
- **AgentDetailScreen** -- full agent info with "Start Chat" action
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
- `AGENTS_ROUTE` ("agents") -> marketplace grid
|
- Sealed interface: `AgentsRoute : NavKey` with typed route classes
|
||||||
- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view
|
- Routes: `AgentMarketplace` (grid), `AgentDetail(agentId: String)` (detail), `AgentEditorCreate`, `AgentEditorEdit(agentId: String)` (all `@Serializable`)
|
||||||
|
- Feature entries registered via `EntryProviderScope<NavKey>.agentsEntries()`
|
||||||
- `onStartChat(agentId)` callback navigates to chat with the selected agent
|
- `onStartChat(agentId)` callback navigates to chat with the selected agent
|
||||||
|
|
||||||
## Marketplace ViewModel
|
## Marketplace ViewModel
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,22 @@ val agentsModule = module {
|
||||||
includes(agentsPlatformModule)
|
includes(agentsPlatformModule)
|
||||||
|
|
||||||
viewModelOf(::AgentMarketplaceViewModel)
|
viewModelOf(::AgentMarketplaceViewModel)
|
||||||
viewModelOf(::AgentDetailViewModel)
|
viewModel { params ->
|
||||||
viewModel {
|
AgentDetailViewModel(
|
||||||
|
savedStateHandle = get(),
|
||||||
|
agentRepository = get(),
|
||||||
|
serverDataStore = get(),
|
||||||
|
initialAgentId = params.getOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModel { params ->
|
||||||
AgentEditorViewModel(
|
AgentEditorViewModel(
|
||||||
savedStateHandle = get(),
|
savedStateHandle = get(),
|
||||||
agentRepository = get(),
|
agentRepository = get(),
|
||||||
configRepository = get(),
|
configRepository = get(),
|
||||||
mcpRepository = get(),
|
mcpRepository = get(),
|
||||||
contentReader = get(),
|
contentReader = get(),
|
||||||
|
initialAgentId = params.getOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,64 @@
|
||||||
package com.garfiec.librechat.feature.agents.navigation
|
package com.garfiec.librechat.feature.agents.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen
|
import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen
|
||||||
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
|
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
|
||||||
import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen
|
import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.polymorphic
|
||||||
|
import kotlinx.serialization.modules.subclass
|
||||||
|
|
||||||
const val AGENTS_ROUTE = "agents"
|
@Serializable sealed interface AgentsRoute : NavKey
|
||||||
const val AGENT_DETAIL_ROUTE = "agents/{agentId}"
|
|
||||||
const val AGENT_EDITOR_CREATE_ROUTE = "agents/editor/create"
|
|
||||||
const val AGENT_EDITOR_EDIT_ROUTE = "agents/editor/{agentId}"
|
|
||||||
|
|
||||||
fun NavGraphBuilder.agentsGraph(
|
@Serializable data object AgentMarketplace : AgentsRoute
|
||||||
onAgentClick: (String) -> Unit,
|
@Serializable data class AgentDetail(val agentId: String) : AgentsRoute
|
||||||
|
@Serializable data object AgentEditorCreate : AgentsRoute
|
||||||
|
@Serializable data class AgentEditorEdit(val agentId: String) : AgentsRoute
|
||||||
|
|
||||||
|
fun EntryProviderScope<NavKey>.agentsEntries(
|
||||||
|
onNavigate: (NavKey) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onStartChat: (String) -> Unit,
|
onStartChat: (String) -> Unit,
|
||||||
onCreateAgent: () -> Unit,
|
|
||||||
onEditAgent: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
composable(AGENTS_ROUTE) {
|
entry<AgentMarketplace> {
|
||||||
AgentMarketplaceScreen(
|
AgentMarketplaceScreen(
|
||||||
onAgentClick = onAgentClick,
|
onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||||
onCreateAgent = onCreateAgent,
|
onCreateAgent = { onNavigate(AgentEditorCreate) },
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
entry<AgentDetail> { key ->
|
||||||
route = AGENT_DETAIL_ROUTE,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("agentId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
AgentDetailScreen(
|
AgentDetailScreen(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onStartChat = onStartChat,
|
onStartChat = onStartChat,
|
||||||
onEdit = onEditAgent,
|
onEdit = { agentId -> onNavigate(AgentEditorEdit(agentId = agentId)) },
|
||||||
onDuplicated = onAgentClick,
|
onDuplicated = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||||
|
agentId = key.agentId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(AGENT_EDITOR_CREATE_ROUTE) {
|
entry<AgentEditorCreate> {
|
||||||
AgentEditorScreen(
|
AgentEditorScreen(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSaved = { agentId -> onAgentClick(agentId) },
|
onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||||
|
agentId = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
entry<AgentEditorEdit> { key ->
|
||||||
route = AGENT_EDITOR_EDIT_ROUTE,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("agentId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
AgentEditorScreen(
|
AgentEditorScreen(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSaved = { agentId -> onAgentClick(agentId) },
|
onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||||
|
agentId = key.agentId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val agentsSerializersModule = SerializersModule {
|
||||||
|
polymorphic(NavKey::class) {
|
||||||
|
subclass(AgentMarketplace::class, AgentMarketplace.serializer())
|
||||||
|
subclass(AgentDetail::class, AgentDetail.serializer())
|
||||||
|
subclass(AgentEditorCreate::class, AgentEditorCreate.serializer())
|
||||||
|
subclass(AgentEditorEdit::class, AgentEditorEdit.serializer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import librechat_mobile.feature.agents.generated.resources.*
|
||||||
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailEvent
|
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailEvent
|
||||||
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel
|
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
|
@ -65,8 +66,9 @@ fun AgentDetailScreen(
|
||||||
onStartChat: (String) -> Unit,
|
onStartChat: (String) -> Unit,
|
||||||
onEdit: (String) -> Unit,
|
onEdit: (String) -> Unit,
|
||||||
onDuplicated: (String) -> Unit,
|
onDuplicated: (String) -> Unit,
|
||||||
|
agentId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgentDetailViewModel = koinViewModel(),
|
viewModel: AgentDetailViewModel = koinViewModel { parametersOf(agentId) },
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,16 +71,18 @@ import com.garfiec.librechat.feature.agents.components.ToolSelectDialog
|
||||||
import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorEvent
|
import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorEvent
|
||||||
import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorViewModel
|
import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorViewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AgentEditorScreen(
|
fun AgentEditorScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSaved: (String) -> Unit,
|
onSaved: (String) -> Unit,
|
||||||
|
agentId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onDeleted: () -> Unit = onBack,
|
onDeleted: () -> Unit = onBack,
|
||||||
onDuplicated: (String) -> Unit = onSaved,
|
onDuplicated: (String) -> Unit = onSaved,
|
||||||
viewModel: AgentEditorViewModel = koinViewModel(),
|
viewModel: AgentEditorViewModel = koinViewModel { parametersOf(agentId) },
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var showToolDialog by rememberSaveable { mutableStateOf(false) }
|
var showToolDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,10 @@ class AgentDetailViewModel(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val agentRepository: AgentRepository,
|
private val agentRepository: AgentRepository,
|
||||||
private val serverDataStore: ServerDataStore,
|
private val serverDataStore: ServerDataStore,
|
||||||
|
initialAgentId: String? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val agentId: String = checkNotNull(savedStateHandle["agentId"])
|
private val agentId: String = checkNotNull(initialAgentId) { "agentId must be provided" }
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AgentDetailUiState())
|
private val _uiState = MutableStateFlow(AgentDetailUiState())
|
||||||
val uiState: StateFlow<AgentDetailUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AgentDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,10 @@ class AgentEditorViewModel(
|
||||||
private val configRepository: ConfigRepository,
|
private val configRepository: ConfigRepository,
|
||||||
private val mcpRepository: McpRepository,
|
private val mcpRepository: McpRepository,
|
||||||
private val contentReader: ContentReader,
|
private val contentReader: ContentReader,
|
||||||
|
initialAgentId: String? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val editAgentId: String? = savedStateHandle["agentId"]
|
private val editAgentId: String? = initialAgentId
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
AgentEditorUiState(
|
AgentEditorUiState(
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@
|
||||||
- **TwoFactorScreen** -- 6-digit OTP input with backup code fallback
|
- **TwoFactorScreen** -- 6-digit OTP input with backup code fallback
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
- Graph route: `AUTH_GRAPH_ROUTE` ("auth_graph"), start destination: `SERVER_URL_ROUTE`
|
- Sealed interface: `AuthRoute : NavKey` with typed route classes
|
||||||
- Flow: ServerUrl -> Login -> (2FA if `tempToken` returned) -> `onAuthComplete`
|
- Routes: `ServerUrl`, `Login`, `Register`, `ForgotPassword`, `TwoFactor(tempToken)`, `VerifyEmail(email)`, `ResetPassword(userId, token)`, `Terms` (all `@Serializable`)
|
||||||
|
- Feature entries registered via `EntryProviderScope<NavKey>.authEntries()`
|
||||||
|
- Flow: `ServerUrl` → `Login` → (2FA if `tempToken` returned) → `onAuthComplete`
|
||||||
- Register and ForgotPassword are lateral routes from Login
|
- Register and ForgotPassword are lateral routes from Login
|
||||||
- `TWO_FACTOR_ROUTE` takes `{tempToken}` nav argument
|
- `TwoFactor(val tempToken: String)` data class carries the nav argument directly
|
||||||
|
|
||||||
## OAuth Flow
|
## OAuth Flow
|
||||||
- `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}`
|
- `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}`
|
||||||
|
|
@ -37,7 +39,7 @@
|
||||||
|
|
||||||
### Terms Screen
|
### Terms Screen
|
||||||
- `TermsScreen` + `TermsViewModel` — displays server terms, "I Accept" button
|
- `TermsScreen` + `TermsViewModel` — displays server terms, "I Accept" button
|
||||||
- Route: `TERMS_ROUTE = "auth/terms"` in `AuthNavigation.kt`
|
- Route: `Terms` data object (part of `AuthRoute` sealed interface) in `AuthNavigation.kt`
|
||||||
- Loads terms text via `UserRepository`, posts acceptance on confirm
|
- Loads terms text via `UserRepository`, posts acceptance on confirm
|
||||||
- `TermsViewModel.consumeAccepted()` resets navigation trigger
|
- `TermsViewModel.consumeAccepted()` resets navigation trigger
|
||||||
- **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true
|
- **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import com.garfiec.librechat.feature.auth.viewmodel.TermsViewModel
|
||||||
import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
|
import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
|
||||||
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
|
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
|
@ -19,9 +20,28 @@ val authModule = module {
|
||||||
viewModelOf(::ServerUrlViewModel)
|
viewModelOf(::ServerUrlViewModel)
|
||||||
viewModelOf(::LoginViewModel)
|
viewModelOf(::LoginViewModel)
|
||||||
viewModelOf(::RegisterViewModel)
|
viewModelOf(::RegisterViewModel)
|
||||||
viewModelOf(::VerifyEmailViewModel)
|
viewModel { params ->
|
||||||
|
VerifyEmailViewModel(
|
||||||
|
savedStateHandle = get(),
|
||||||
|
userRepository = get(),
|
||||||
|
initialEmail = params.getOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
viewModelOf(::ForgotPasswordViewModel)
|
viewModelOf(::ForgotPasswordViewModel)
|
||||||
viewModelOf(::ResetPasswordViewModel)
|
viewModel { params ->
|
||||||
viewModelOf(::TwoFactorViewModel)
|
ResetPasswordViewModel(
|
||||||
|
savedStateHandle = get(),
|
||||||
|
authRepository = get(),
|
||||||
|
initialUserId = params.getOrNull(),
|
||||||
|
initialToken = params.getOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModel { params ->
|
||||||
|
TwoFactorViewModel(
|
||||||
|
savedStateHandle = get(),
|
||||||
|
authRepository = get(),
|
||||||
|
initialTempToken = params.getOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
viewModelOf(::TermsViewModel)
|
viewModelOf(::TermsViewModel)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
package com.garfiec.librechat.feature.auth.navigation
|
package com.garfiec.librechat.feature.auth.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import androidx.navigation.navigation
|
|
||||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
|
||||||
import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
|
import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
|
||||||
import com.garfiec.librechat.feature.auth.screen.LoginScreen
|
import com.garfiec.librechat.feature.auth.screen.LoginScreen
|
||||||
import com.garfiec.librechat.feature.auth.screen.RegisterScreen
|
import com.garfiec.librechat.feature.auth.screen.RegisterScreen
|
||||||
|
|
@ -15,123 +10,92 @@ import com.garfiec.librechat.feature.auth.screen.ServerUrlScreen
|
||||||
import com.garfiec.librechat.feature.auth.screen.TermsScreen
|
import com.garfiec.librechat.feature.auth.screen.TermsScreen
|
||||||
import com.garfiec.librechat.feature.auth.screen.TwoFactorScreen
|
import com.garfiec.librechat.feature.auth.screen.TwoFactorScreen
|
||||||
import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen
|
import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen
|
||||||
const val AUTH_GRAPH_ROUTE = "auth_graph"
|
import kotlinx.serialization.Serializable
|
||||||
const val SERVER_URL_ROUTE = "server_url"
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
const val LOGIN_ROUTE = "login"
|
import kotlinx.serialization.modules.polymorphic
|
||||||
const val REGISTER_ROUTE = "register"
|
import kotlinx.serialization.modules.subclass
|
||||||
const val FORGOT_PASSWORD_ROUTE = "forgot_password"
|
|
||||||
const val TWO_FACTOR_ROUTE = "two_factor/{tempToken}"
|
|
||||||
const val VERIFY_EMAIL_ROUTE = "verify_email/{email}"
|
|
||||||
const val TERMS_ROUTE = "auth/terms"
|
|
||||||
const val RESET_PASSWORD_ROUTE = "reset_password/{userId}/{token}"
|
|
||||||
|
|
||||||
fun NavController.navigateToVerifyEmail(email: String) {
|
@Serializable sealed interface AuthRoute : NavKey
|
||||||
navigate("verify_email/${encodeNavArg(email)}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavController.navigateToResetPassword(userId: String, token: String) {
|
@Serializable data object ServerUrl : AuthRoute
|
||||||
navigate("reset_password/${encodeNavArg(userId)}/${encodeNavArg(token)}")
|
@Serializable data object Login : AuthRoute
|
||||||
}
|
@Serializable data object Register : AuthRoute
|
||||||
|
@Serializable data object ForgotPassword : AuthRoute
|
||||||
|
@Serializable data class TwoFactor(val tempToken: String) : AuthRoute
|
||||||
|
@Serializable data class VerifyEmail(val email: String) : AuthRoute
|
||||||
|
@Serializable data object Terms : AuthRoute
|
||||||
|
@Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute
|
||||||
|
|
||||||
private fun encodeNavArg(value: String): String = buildString {
|
fun EntryProviderScope<NavKey>.authEntries(
|
||||||
for (c in value) {
|
onNavigate: (NavKey) -> Unit,
|
||||||
when {
|
onBack: () -> Unit,
|
||||||
c.isLetterOrDigit() || c in "-._~" -> append(c)
|
|
||||||
else -> {
|
|
||||||
val bytes = c.toString().encodeToByteArray()
|
|
||||||
for (b in bytes) {
|
|
||||||
append('%')
|
|
||||||
append(
|
|
||||||
(b.toInt() and 0xFF).toString(16).uppercase().padStart(2, '0'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavGraphBuilder.authGraph(
|
|
||||||
navController: NavController,
|
|
||||||
onAuthComplete: () -> Unit,
|
onAuthComplete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
navigation(startDestination = SERVER_URL_ROUTE, route = AUTH_GRAPH_ROUTE) {
|
entry<ServerUrl> {
|
||||||
composable(SERVER_URL_ROUTE) {
|
ServerUrlScreen(
|
||||||
ScreenTransitionWrapper(transition) {
|
onServerValidated = { onNavigate(Login) },
|
||||||
ServerUrlScreen(
|
)
|
||||||
onServerValidated = { navController.navigate(LOGIN_ROUTE) },
|
}
|
||||||
)
|
entry<Login> {
|
||||||
}
|
LoginScreen(
|
||||||
}
|
onLoginSuccess = onAuthComplete,
|
||||||
composable(LOGIN_ROUTE) {
|
onNavigateToRegister = { onNavigate(Register) },
|
||||||
ScreenTransitionWrapper(transition) {
|
onNavigateToForgotPassword = { onNavigate(ForgotPassword) },
|
||||||
LoginScreen(
|
onNavigateToTwoFactor = { tempToken ->
|
||||||
onLoginSuccess = onAuthComplete,
|
onNavigate(TwoFactor(tempToken = tempToken))
|
||||||
onNavigateToRegister = { navController.navigate(REGISTER_ROUTE) },
|
},
|
||||||
onNavigateToForgotPassword = { navController.navigate(FORGOT_PASSWORD_ROUTE) },
|
)
|
||||||
onNavigateToTwoFactor = { tempToken ->
|
}
|
||||||
navController.navigate("two_factor/$tempToken")
|
entry<Register> {
|
||||||
},
|
RegisterScreen(
|
||||||
)
|
onRegistered = onBack,
|
||||||
}
|
onNavigateToLogin = onBack,
|
||||||
}
|
)
|
||||||
composable(REGISTER_ROUTE) {
|
}
|
||||||
ScreenTransitionWrapper(transition) {
|
entry<ForgotPassword> {
|
||||||
RegisterScreen(
|
ForgotPasswordScreen(
|
||||||
onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
|
onBack = onBack,
|
||||||
onNavigateToLogin = { navController.popBackStack() },
|
)
|
||||||
)
|
}
|
||||||
}
|
entry<TwoFactor> { key ->
|
||||||
}
|
TwoFactorScreen(
|
||||||
composable(FORGOT_PASSWORD_ROUTE) {
|
onVerified = onAuthComplete,
|
||||||
ScreenTransitionWrapper(transition) {
|
onBack = onBack,
|
||||||
ForgotPasswordScreen(
|
tempToken = key.tempToken,
|
||||||
onBack = { navController.popBackStack() },
|
)
|
||||||
)
|
}
|
||||||
}
|
entry<VerifyEmail> { key ->
|
||||||
}
|
VerifyEmailScreen(
|
||||||
composable(
|
onVerified = onBack,
|
||||||
route = TWO_FACTOR_ROUTE,
|
onBack = onBack,
|
||||||
arguments = listOf(navArgument("tempToken") { type = NavType.StringType }),
|
email = key.email,
|
||||||
) {
|
)
|
||||||
ScreenTransitionWrapper(transition) {
|
}
|
||||||
TwoFactorScreen(
|
entry<Terms> {
|
||||||
onVerified = onAuthComplete,
|
TermsScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onAccepted = onAuthComplete,
|
||||||
)
|
onBack = onBack,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
composable(
|
entry<ResetPassword> { key ->
|
||||||
route = VERIFY_EMAIL_ROUTE,
|
ResetPasswordScreen(
|
||||||
arguments = listOf(navArgument("email") { type = NavType.StringType }),
|
onResetComplete = onBack,
|
||||||
) {
|
onBack = onBack,
|
||||||
ScreenTransitionWrapper(transition) {
|
userId = key.userId,
|
||||||
VerifyEmailScreen(
|
token = key.token,
|
||||||
onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
|
)
|
||||||
onBack = { navController.popBackStack() },
|
}
|
||||||
)
|
}
|
||||||
}
|
|
||||||
}
|
val authSerializersModule = SerializersModule {
|
||||||
composable(TERMS_ROUTE) {
|
polymorphic(NavKey::class) {
|
||||||
ScreenTransitionWrapper(transition) {
|
subclass(ServerUrl::class, ServerUrl.serializer())
|
||||||
TermsScreen(
|
subclass(Login::class, Login.serializer())
|
||||||
onAccepted = onAuthComplete,
|
subclass(Register::class, Register.serializer())
|
||||||
onBack = { navController.popBackStack() },
|
subclass(ForgotPassword::class, ForgotPassword.serializer())
|
||||||
)
|
subclass(TwoFactor::class, TwoFactor.serializer())
|
||||||
}
|
subclass(VerifyEmail::class, VerifyEmail.serializer())
|
||||||
}
|
subclass(Terms::class, Terms.serializer())
|
||||||
composable(
|
subclass(ResetPassword::class, ResetPassword.serializer())
|
||||||
route = RESET_PASSWORD_ROUTE,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("userId") { type = NavType.StringType },
|
|
||||||
navArgument("token") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
ScreenTransitionWrapper(transition) {
|
|
||||||
ResetPasswordScreen(
|
|
||||||
onResetComplete = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,17 @@ import librechat_mobile.feature.auth.generated.resources.Res
|
||||||
import librechat_mobile.feature.auth.generated.resources.*
|
import librechat_mobile.feature.auth.generated.resources.*
|
||||||
import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel
|
import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ResetPasswordScreen(
|
fun ResetPasswordScreen(
|
||||||
onResetComplete: () -> Unit,
|
onResetComplete: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
userId: String? = null,
|
||||||
|
token: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ResetPasswordViewModel = koinViewModel(),
|
viewModel: ResetPasswordViewModel = koinViewModel { parametersOf(userId, token) },
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,16 @@ import librechat_mobile.feature.auth.generated.resources.Res
|
||||||
import librechat_mobile.feature.auth.generated.resources.*
|
import librechat_mobile.feature.auth.generated.resources.*
|
||||||
import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
|
import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TwoFactorScreen(
|
fun TwoFactorScreen(
|
||||||
onVerified: () -> Unit,
|
onVerified: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
tempToken: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: TwoFactorViewModel = koinViewModel(),
|
viewModel: TwoFactorViewModel = koinViewModel { parametersOf(tempToken) },
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,16 @@ import librechat_mobile.feature.auth.generated.resources.Res
|
||||||
import librechat_mobile.feature.auth.generated.resources.*
|
import librechat_mobile.feature.auth.generated.resources.*
|
||||||
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
|
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun VerifyEmailScreen(
|
fun VerifyEmailScreen(
|
||||||
onVerified: () -> Unit,
|
onVerified: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
email: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: VerifyEmailViewModel = koinViewModel(),
|
viewModel: VerifyEmailViewModel = koinViewModel { parametersOf(email) },
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val email = uiState.email
|
val email = uiState.email
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ data class ResetPasswordUiState(
|
||||||
class ResetPasswordViewModel(
|
class ResetPasswordViewModel(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
|
initialUserId: String? = null,
|
||||||
|
initialToken: String? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val userId: String = savedStateHandle["userId"] ?: ""
|
private val userId: String = initialUserId ?: ""
|
||||||
private val token: String = savedStateHandle["token"] ?: ""
|
private val token: String = initialToken ?: ""
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ResetPasswordUiState())
|
private val _uiState = MutableStateFlow(ResetPasswordUiState())
|
||||||
val uiState: StateFlow<ResetPasswordUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ResetPasswordUiState> = _uiState.asStateFlow()
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@ data class TwoFactorUiState(
|
||||||
class TwoFactorViewModel(
|
class TwoFactorViewModel(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
|
initialTempToken: String? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val tempToken: String = savedStateHandle["tempToken"] ?: ""
|
private val tempToken: String = initialTempToken ?: ""
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(TwoFactorUiState())
|
private val _uiState = MutableStateFlow(TwoFactorUiState())
|
||||||
val uiState: StateFlow<TwoFactorUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<TwoFactorUiState> = _uiState.asStateFlow()
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,11 @@ data class VerifyEmailUiState(
|
||||||
class VerifyEmailViewModel(
|
class VerifyEmailViewModel(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
|
initialEmail: String? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
VerifyEmailUiState(email = savedStateHandle["email"] ?: ""),
|
VerifyEmailUiState(email = initialEmail ?: ""),
|
||||||
)
|
)
|
||||||
val uiState: StateFlow<VerifyEmailUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<VerifyEmailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@
|
||||||
- **Active**: message list + streaming content + input area
|
- **Active**: message list + streaming content + input area
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}")
|
- Sealed interface: `ChatRoute : NavKey` with typed route classes
|
||||||
- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen
|
- Routes: `NewChat` (landing), `Chat(conversationId: String? = null)`, `PromptsLibrary`, `PromptEditor(groupId: String? = null)` (all `@Serializable`)
|
||||||
- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `chat/{id}` after the first stream completes (data persisted to Room), keeping `new_chat` in the back stack
|
- Feature entries registered via `EntryProviderScope<NavKey>.chatEntries()`
|
||||||
- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat`
|
- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `Chat(id)` after the first stream completes (data persisted to Room), keeping `NewChat` in the back stack
|
||||||
|
- `navigateToChat()` replaces the current chat entry on the `NavBackStack` when switching between chats so back returns to `NewChat`
|
||||||
|
|
||||||
## Message Tree
|
## Message Tree
|
||||||
- Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID)
|
- Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@ actual val chatPlatformModule: Module = module {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
viewModel { params ->
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
savedStateHandle = get(),
|
savedStateHandle = get(),
|
||||||
|
initialConversationId = params.getOrNull(),
|
||||||
agentRepository = get(),
|
agentRepository = get(),
|
||||||
chatRepository = get(),
|
chatRepository = get(),
|
||||||
messageRepository = get(),
|
messageRepository = get(),
|
||||||
|
|
|
||||||
|
|
@ -97,18 +97,20 @@ import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
|
||||||
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun ChatScreen(
|
actual fun ChatScreen(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
conversationId: String?,
|
||||||
onConversationStarted: ((String) -> Unit)?,
|
onConversationStarted: ((String) -> Unit)?,
|
||||||
onNavigateToConversation: ((String) -> Unit)?,
|
onNavigateToConversation: ((String) -> Unit)?,
|
||||||
onOpenDrawer: (() -> Unit)?,
|
onOpenDrawer: (() -> Unit)?,
|
||||||
onNavigateToPromptsLibrary: (() -> Unit)?,
|
onNavigateToPromptsLibrary: (() -> Unit)?,
|
||||||
onNavigateBack: (() -> Unit)?,
|
onNavigateBack: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val viewModel: ChatViewModel = koinViewModel()
|
val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
||||||
val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle()
|
val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle()
|
||||||
|
|
@ -232,9 +234,9 @@ actual fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a new conversation starts, navigate to chat/{conversationId} immediately
|
// When a new conversation starts, navigate to Chat(conversationId) immediately
|
||||||
// (at StreamEvent.Created) so the new_chat landing page stays clean in the back
|
// (at StreamEvent.Created) so the NewChat landing page stays clean in the back
|
||||||
// stack. The new ChatViewModel at chat/{id} will resume the active stream.
|
// stack. The new ChatViewModel at Chat(id) will resume the active stream.
|
||||||
// onPendingNavigationHandled() resets this ViewModel to a fresh landing state.
|
// onPendingNavigationHandled() resets this ViewModel to a fresh landing state.
|
||||||
LaunchedEffect(uiState.pendingNavigationConversationId) {
|
LaunchedEffect(uiState.pendingNavigationConversationId) {
|
||||||
val pendingId = uiState.pendingNavigationConversationId
|
val pendingId = uiState.pendingNavigationConversationId
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,20 @@ package com.garfiec.librechat.feature.chat.di
|
||||||
import com.garfiec.librechat.feature.chat.prompts.PromptEditorViewModel
|
import com.garfiec.librechat.feature.chat.prompts.PromptEditorViewModel
|
||||||
import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel
|
import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val chatModule = module {
|
val chatModule = module {
|
||||||
includes(chatPlatformModule)
|
includes(chatPlatformModule)
|
||||||
viewModelOf(::PromptsViewModel)
|
viewModelOf(::PromptsViewModel)
|
||||||
viewModelOf(::PromptEditorViewModel)
|
viewModel { params ->
|
||||||
|
PromptEditorViewModel(
|
||||||
|
promptRepository = get(),
|
||||||
|
savedStateHandle = get(),
|
||||||
|
initialGroupId = params.getOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect val chatPlatformModule: Module
|
expect val chatPlatformModule: Module
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,69 @@
|
||||||
package com.garfiec.librechat.feature.chat.navigation
|
package com.garfiec.librechat.feature.chat.navigation
|
||||||
|
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import androidx.navigation.navigation
|
|
||||||
import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
|
import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
|
||||||
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
|
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
|
||||||
import com.garfiec.librechat.feature.chat.screen.ChatScreen
|
import com.garfiec.librechat.feature.chat.screen.ChatScreen
|
||||||
import com.garfiec.librechat.feature.chat.screen.NewChatScreen
|
import com.garfiec.librechat.feature.chat.screen.NewChatScreen
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.polymorphic
|
||||||
|
import kotlinx.serialization.modules.subclass
|
||||||
|
|
||||||
const val CHAT_GRAPH_ROUTE = "chat_graph"
|
@Serializable sealed interface ChatRoute : NavKey
|
||||||
const val NEW_CHAT_ROUTE = "new_chat"
|
|
||||||
const val CHAT_ROUTE = "chat/{conversationId}"
|
|
||||||
const val PROMPTS_LIBRARY_ROUTE = "prompts_library"
|
|
||||||
const val PROMPT_EDITOR_ROUTE = "prompt_editor?groupId={groupId}"
|
|
||||||
|
|
||||||
fun NavController.navigateToChat(conversationId: String) {
|
@Serializable data object NewChat : ChatRoute
|
||||||
val currentEntry = currentBackStackEntry
|
@Serializable data class Chat(val conversationId: String? = null) : ChatRoute
|
||||||
val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE
|
@Serializable data object PromptsLibrary : ChatRoute
|
||||||
val currentDestId = currentEntry?.destination?.id
|
@Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute
|
||||||
navigate("chat/$conversationId") {
|
|
||||||
if (isCurrentlyInChat && currentDestId != null) {
|
|
||||||
// Already viewing a chat -- replace it so switching chats
|
|
||||||
// doesn't stack entries. Back will go to whatever was
|
|
||||||
// before the first chat (e.g. new_chat, settings, agents).
|
|
||||||
popUpTo(currentDestId) { inclusive = true }
|
|
||||||
}
|
|
||||||
// From non-chat screens just push onto the backstack normally
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavController.navigateToPromptsLibrary() {
|
fun EntryProviderScope<NavKey>.chatEntries(
|
||||||
navigate(PROMPTS_LIBRARY_ROUTE)
|
onNavigate: (NavKey) -> Unit,
|
||||||
}
|
onBack: () -> Unit,
|
||||||
|
onNavigateToChat: (String) -> Unit,
|
||||||
fun NavController.navigateToPromptEditor(groupId: String? = null) {
|
|
||||||
if (groupId != null) {
|
|
||||||
navigate("prompt_editor?groupId=$groupId")
|
|
||||||
} else {
|
|
||||||
navigate("prompt_editor")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavGraphBuilder.chatGraph(
|
|
||||||
navController: NavController,
|
|
||||||
onOpenDrawer: (() -> Unit)? = null,
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) {
|
entry<NewChat> {
|
||||||
composable(
|
NewChatScreen(
|
||||||
route = NEW_CHAT_ROUTE,
|
onConversationStarted = { conversationId ->
|
||||||
enterTransition = { EnterTransition.None },
|
onNavigateToChat(conversationId)
|
||||||
exitTransition = { ExitTransition.None },
|
},
|
||||||
popEnterTransition = { null },
|
onOpenDrawer = onOpenDrawer,
|
||||||
popExitTransition = { null },
|
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
|
||||||
) {
|
)
|
||||||
NewChatScreen(
|
}
|
||||||
onConversationStarted = { conversationId ->
|
entry<Chat> { key ->
|
||||||
navController.navigateToChat(conversationId)
|
ChatScreen(
|
||||||
},
|
conversationId = key.conversationId,
|
||||||
onOpenDrawer = onOpenDrawer,
|
onOpenDrawer = onOpenDrawer,
|
||||||
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
|
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
|
||||||
)
|
onNavigateBack = onBack,
|
||||||
}
|
onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) },
|
||||||
composable(
|
)
|
||||||
route = CHAT_ROUTE,
|
}
|
||||||
arguments = listOf(
|
entry<PromptsLibrary> {
|
||||||
navArgument("conversationId") {
|
PromptsLibraryScreen(
|
||||||
type = NavType.StringType
|
onNavigateBack = onBack,
|
||||||
nullable = true
|
onUseInChat = { _ -> onBack() },
|
||||||
defaultValue = null
|
onNavigateToEditor = { groupId ->
|
||||||
},
|
onNavigate(PromptEditor(groupId = groupId))
|
||||||
),
|
},
|
||||||
enterTransition = { EnterTransition.None },
|
)
|
||||||
exitTransition = { ExitTransition.None },
|
}
|
||||||
popEnterTransition = { null },
|
entry<PromptEditor> { key ->
|
||||||
popExitTransition = { null },
|
PromptEditorScreen(
|
||||||
) { _ ->
|
onBack = onBack,
|
||||||
ChatScreen(
|
groupId = key.groupId,
|
||||||
onOpenDrawer = onOpenDrawer,
|
)
|
||||||
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
|
}
|
||||||
onNavigateBack = { navController.popBackStack() },
|
}
|
||||||
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) },
|
|
||||||
)
|
val chatSerializersModule = SerializersModule {
|
||||||
}
|
polymorphic(NavKey::class) {
|
||||||
composable(PROMPTS_LIBRARY_ROUTE) {
|
subclass(NewChat::class, NewChat.serializer())
|
||||||
PromptsLibraryScreen(
|
subclass(Chat::class, Chat.serializer())
|
||||||
onNavigateBack = { navController.popBackStack() },
|
subclass(PromptsLibrary::class, PromptsLibrary.serializer())
|
||||||
onUseInChat = { promptText ->
|
subclass(PromptEditor::class, PromptEditor.serializer())
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onNavigateToEditor = { groupId ->
|
|
||||||
navController.navigateToPromptEditor(groupId)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = PROMPT_EDITOR_ROUTE,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("groupId") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
PromptEditorScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,15 @@ import com.garfiec.librechat.core.ui.components.LoadingIndicator
|
||||||
import librechat_mobile.feature.chat.generated.resources.Res
|
import librechat_mobile.feature.chat.generated.resources.Res
|
||||||
import librechat_mobile.feature.chat.generated.resources.*
|
import librechat_mobile.feature.chat.generated.resources.*
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PromptEditorScreen(
|
fun PromptEditorScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PromptEditorViewModel = koinViewModel(),
|
groupId: String? = null,
|
||||||
|
viewModel: PromptEditorViewModel = koinViewModel { parametersOf(groupId) },
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,14 @@ data class PromptEditorUiState(
|
||||||
class PromptEditorViewModel(
|
class PromptEditorViewModel(
|
||||||
private val promptRepository: PromptRepository,
|
private val promptRepository: PromptRepository,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
|
initialGroupId: String? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(PromptEditorUiState())
|
private val _uiState = MutableStateFlow(PromptEditorUiState())
|
||||||
val uiState: StateFlow<PromptEditorUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PromptEditorUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val groupId = savedStateHandle.get<String>("groupId")
|
val groupId = initialGroupId
|
||||||
if (groupId != null) {
|
if (groupId != null) {
|
||||||
loadGroup(groupId)
|
loadGroup(groupId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
|
||||||
@Composable
|
@Composable
|
||||||
expect fun ChatScreen(
|
expect fun ChatScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
conversationId: String? = null,
|
||||||
onConversationStarted: ((String) -> Unit)? = null,
|
onConversationStarted: ((String) -> Unit)? = null,
|
||||||
onNavigateToConversation: ((String) -> Unit)? = null,
|
onNavigateToConversation: ((String) -> Unit)? = null,
|
||||||
onOpenDrawer: (() -> Unit)? = null,
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ data class ChatUiState(
|
||||||
val showDeleteConfirmation: Boolean = false,
|
val showDeleteConfirmation: Boolean = false,
|
||||||
val duplicatedConversationId: String? = null,
|
val duplicatedConversationId: String? = null,
|
||||||
/** Set at StreamEvent.Created when a new conversation's conversationId becomes available.
|
/** Set at StreamEvent.Created when a new conversation's conversationId becomes available.
|
||||||
* The UI navigates to chat/{id} and then clears this via [ChatViewModel.onPendingNavigationHandled],
|
* The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled],
|
||||||
* which also resets this ViewModel to a clean landing state. */
|
* which also resets this ViewModel to a clean landing state. */
|
||||||
val pendingNavigationConversationId: String? = null,
|
val pendingNavigationConversationId: String? = null,
|
||||||
// Model comparison state
|
// Model comparison state
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import kotlin.uuid.Uuid
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
|
initialConversationId: String? = null,
|
||||||
private val agentRepository: AgentRepository,
|
private val agentRepository: AgentRepository,
|
||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val messageRepository: MessageRepository,
|
private val messageRepository: MessageRepository,
|
||||||
|
|
@ -163,7 +164,7 @@ class ChatViewModel(
|
||||||
private var titleGenerationRequested = false
|
private var titleGenerationRequested = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val conversationId = savedStateHandle.get<String>("conversationId")
|
val conversationId = initialConversationId
|
||||||
isNewConversation = conversationId == null
|
isNewConversation = conversationId == null
|
||||||
if (conversationId != null) {
|
if (conversationId != null) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
|
|
@ -174,7 +175,7 @@ class ChatViewModel(
|
||||||
loadConversationModel(conversationId)
|
loadConversationModel(conversationId)
|
||||||
restoreDraft(conversationId)
|
restoreDraft(conversationId)
|
||||||
// Check if there's an active stream for this conversation (e.g. when
|
// Check if there's an active stream for this conversation (e.g. when
|
||||||
// navigating here from new_chat immediately after sending). If so,
|
// navigating here from NewChat immediately after sending). If so,
|
||||||
// resume it so the user sees streaming content on this screen.
|
// resume it so the user sees streaming content on this screen.
|
||||||
resumeActiveStreamIfNeeded(conversationId)
|
resumeActiveStreamIfNeeded(conversationId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -255,7 +256,7 @@ class ChatViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore MCP server and tool selections from DataStore so they
|
// Restore MCP server and tool selections from DataStore so they
|
||||||
// survive the new_chat -> chat/{id} navigation re-creation.
|
// survive the NewChat -> Chat(id) navigation re-creation.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val mcpServers = settingsDataStore.selectedMcpServers.first()
|
val mcpServers = settingsDataStore.selectedMcpServers.first()
|
||||||
val tools = settingsDataStore.enabledTools.first()
|
val tools = settingsDataStore.enabledTools.first()
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ actual val chatPlatformModule: Module = module {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
viewModel { params ->
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
savedStateHandle = get(),
|
savedStateHandle = get(),
|
||||||
|
initialConversationId = params.getOrNull(),
|
||||||
agentRepository = get(),
|
agentRepository = get(),
|
||||||
chatRepository = get(),
|
chatRepository = get(),
|
||||||
messageRepository = get(),
|
messageRepository = get(),
|
||||||
|
|
|
||||||
|
|
@ -81,18 +81,20 @@ import librechat_mobile.feature.chat.generated.resources.Res
|
||||||
import librechat_mobile.feature.chat.generated.resources.*
|
import librechat_mobile.feature.chat.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun ChatScreen(
|
actual fun ChatScreen(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
conversationId: String?,
|
||||||
onConversationStarted: ((String) -> Unit)?,
|
onConversationStarted: ((String) -> Unit)?,
|
||||||
onNavigateToConversation: ((String) -> Unit)?,
|
onNavigateToConversation: ((String) -> Unit)?,
|
||||||
onOpenDrawer: (() -> Unit)?,
|
onOpenDrawer: (() -> Unit)?,
|
||||||
onNavigateToPromptsLibrary: (() -> Unit)?,
|
onNavigateToPromptsLibrary: (() -> Unit)?,
|
||||||
onNavigateBack: (() -> Unit)?,
|
onNavigateBack: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val viewModel: ChatViewModel = koinViewModel()
|
val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
||||||
val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle()
|
val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# feature:conversations
|
# feature:conversations
|
||||||
|
|
||||||
## Screen
|
## Screen
|
||||||
`ConversationListScreen` -- the drawer content showing all conversations. Single route: `CONVERSATIONS_ROUTE`.
|
`ConversationListScreen` -- the drawer content showing all conversations. Sealed interface: `ConversationsRoute : NavKey` with routes `Conversations` and `ArchivedConversations` (both `@Serializable` data objects).
|
||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
- Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)`
|
- Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)`
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,40 @@
|
||||||
package com.garfiec.librechat.feature.conversations.navigation
|
package com.garfiec.librechat.feature.conversations.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
|
||||||
import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen
|
import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen
|
||||||
import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen
|
import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.polymorphic
|
||||||
|
import kotlinx.serialization.modules.subclass
|
||||||
|
|
||||||
const val CONVERSATIONS_ROUTE = "conversations"
|
@Serializable sealed interface ConversationsRoute : NavKey
|
||||||
const val ARCHIVED_ROUTE = "conversations/archived"
|
|
||||||
|
|
||||||
fun NavGraphBuilder.conversationsGraph(
|
@Serializable data object Conversations : ConversationsRoute
|
||||||
|
@Serializable data object ArchivedConversations : ConversationsRoute
|
||||||
|
|
||||||
|
fun EntryProviderScope<NavKey>.conversationsEntries(
|
||||||
onConversationClick: (String) -> Unit,
|
onConversationClick: (String) -> Unit,
|
||||||
onNavigateToArchived: () -> Unit = {},
|
onNavigateToArchived: () -> Unit = {},
|
||||||
onNavigateBackFromArchived: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
composable(CONVERSATIONS_ROUTE) {
|
entry<Conversations> {
|
||||||
ScreenTransitionWrapper(transition) {
|
ConversationListScreen(
|
||||||
ConversationListScreen(
|
onConversationClick = onConversationClick,
|
||||||
onConversationClick = onConversationClick,
|
onNavigateToArchived = onNavigateToArchived,
|
||||||
onNavigateToArchived = onNavigateToArchived,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(ARCHIVED_ROUTE) {
|
entry<ArchivedConversations> {
|
||||||
ScreenTransitionWrapper(transition) {
|
ArchivedConversationsScreen(
|
||||||
ArchivedConversationsScreen(
|
onNavigateBack = onBack,
|
||||||
onNavigateBack = onNavigateBackFromArchived,
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val conversationsSerializersModule = SerializersModule {
|
||||||
|
polymorphic(NavKey::class) {
|
||||||
|
subclass(Conversations::class, Conversations.serializer())
|
||||||
|
subclass(ArchivedConversations::class, ArchivedConversations.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# feature:files
|
# feature:files
|
||||||
|
|
||||||
## Screen
|
## Screen
|
||||||
`FilesScreen` -- single route `FILES_ROUTE`. Lists uploaded files with upload and delete actions.
|
`FilesScreen` -- sealed interface `FilesRoute : NavKey` with single route `Files` (`@Serializable` data object). Lists uploaded files with upload and delete actions.
|
||||||
|
|
||||||
## ViewModel
|
## ViewModel
|
||||||
`FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver).
|
`FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver).
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
package com.garfiec.librechat.feature.files.navigation
|
package com.garfiec.librechat.feature.files.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
|
||||||
import com.garfiec.librechat.feature.files.screen.FilesScreen
|
import com.garfiec.librechat.feature.files.screen.FilesScreen
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.polymorphic
|
||||||
|
import kotlinx.serialization.modules.subclass
|
||||||
|
|
||||||
const val FILES_ROUTE = "files"
|
@Serializable sealed interface FilesRoute : NavKey
|
||||||
|
|
||||||
fun NavGraphBuilder.filesGraph(
|
@Serializable data object Files : FilesRoute
|
||||||
|
|
||||||
|
fun EntryProviderScope<NavKey>.filesEntries(
|
||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
composable(FILES_ROUTE) {
|
entry<Files> {
|
||||||
ScreenTransitionWrapper(transition) {
|
FilesScreen(onBack = onBack)
|
||||||
FilesScreen(onBack = onBack)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val filesSerializersModule = SerializersModule {
|
||||||
|
polymorphic(NavKey::class) {
|
||||||
|
subclass(Files::class, Files.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# feature:settings
|
# feature:settings
|
||||||
|
|
||||||
## Screen
|
## Screen
|
||||||
`SettingsScreen` -- single route `SETTINGS_ROUTE`. Receives `onLogout` and `onNavigateBack` callbacks.
|
`SettingsScreen` -- sealed interface `SettingsRoute : NavKey` with primary route `SettingsTabbed` (`@Serializable` data object). Receives `onLogout` and `onNavigateBack` callbacks.
|
||||||
|
|
||||||
## Sections
|
## Sections
|
||||||
- **Account**: displays user profile from `UserApi.getUser()`
|
- **Account**: displays user profile from `UserApi.getUser()`
|
||||||
|
|
@ -46,14 +46,14 @@
|
||||||
- `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`)
|
- `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`)
|
||||||
- `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`)
|
- `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`)
|
||||||
- `CommandsConfigScreen` — enable/disable slash commands
|
- `CommandsConfigScreen` — enable/disable slash commands
|
||||||
- Routes: `MEMORIES_ROUTE`, `MCP_SERVERS_ROUTE`, `PRESET_MANAGER_ROUTE`, `COMMANDS_CONFIG_ROUTE`
|
- Routes: `Memories`, `McpServers`, `PresetManager` (all `@Serializable` data objects extending `SettingsRoute`)
|
||||||
- **Gotcha**: Memories and MCP navigation routes defined in their own files (`MemoriesNavigation.kt`, `McpNavigation.kt`) but wired through `SettingsNavigation.kt`
|
- **Gotcha**: Memories and MCP navigation routes defined in their own files (`MemoriesNavigation.kt`, `McpNavigation.kt`) but wired through `SettingsNavigation.kt`
|
||||||
- **Gotcha**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations
|
- **Gotcha**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations
|
||||||
- **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field)
|
- **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field)
|
||||||
|
|
||||||
### API Keys
|
### API Keys
|
||||||
- `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`)
|
- `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`)
|
||||||
- Route: `API_KEYS_ROUTE` ("settings/api_keys"), accessible from Settings → Security section
|
- Route: `ApiKeys` (`@Serializable` data object extending `SettingsRoute`), accessible from Settings → Security section
|
||||||
- `ApiKeyCreateDialog` is two-phase: name input → show created key value with copy button
|
- `ApiKeyCreateDialog` is two-phase: name input → show created key value with copy button
|
||||||
- **Gotcha**: The key value is only available in the creation response — it cannot be retrieved again from the server. The dialog must show it immediately after creation.
|
- **Gotcha**: The key value is only available in the creation response — it cannot be retrieved again from the server. The dialog must show it immediately after creation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
package com.garfiec.librechat.feature.settings.navigation
|
package com.garfiec.librechat.feature.settings.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.garfiec.librechat.feature.settings.screen.McpServersScreen
|
import com.garfiec.librechat.feature.settings.screen.McpServersScreen
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
const val MCP_SERVERS_ROUTE = "settings/mcp"
|
@Serializable data object McpServers : SettingsRoute
|
||||||
|
|
||||||
fun NavGraphBuilder.mcpServersScreen(
|
fun EntryProviderScope<NavKey>.mcpServersEntry(
|
||||||
onNavigateBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
composable(MCP_SERVERS_ROUTE) {
|
entry<McpServers> {
|
||||||
McpServersScreen(
|
McpServersScreen(
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
package com.garfiec.librechat.feature.settings.navigation
|
package com.garfiec.librechat.feature.settings.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.garfiec.librechat.feature.settings.screen.MemoriesScreen
|
import com.garfiec.librechat.feature.settings.screen.MemoriesScreen
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
const val MEMORIES_ROUTE = "settings/memories"
|
@Serializable data object Memories : SettingsRoute
|
||||||
|
|
||||||
fun NavGraphBuilder.memoriesScreen(
|
fun EntryProviderScope<NavKey>.memoriesEntry(
|
||||||
onNavigateBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
composable(MEMORIES_ROUTE) {
|
entry<Memories> {
|
||||||
MemoriesScreen(
|
MemoriesScreen(
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ package com.garfiec.librechat.feature.settings.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen
|
import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen
|
||||||
import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen
|
import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen
|
||||||
import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen
|
import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen
|
||||||
|
|
@ -13,65 +13,65 @@ import com.garfiec.librechat.feature.settings.screen.PresetManagerScreen
|
||||||
import com.garfiec.librechat.feature.settings.screen.SharedLinksScreen
|
import com.garfiec.librechat.feature.settings.screen.SharedLinksScreen
|
||||||
import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen
|
import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen
|
||||||
import com.garfiec.librechat.feature.settings.viewmodel.SettingsViewModel
|
import com.garfiec.librechat.feature.settings.viewmodel.SettingsViewModel
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.polymorphic
|
||||||
|
import kotlinx.serialization.modules.subclass
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
const val SETTINGS_TABBED_ROUTE = "settings"
|
@Serializable sealed interface SettingsRoute : NavKey
|
||||||
const val SETTINGS_GENERAL_ROUTE = "settings/general"
|
|
||||||
const val SETTINGS_CHAT_ROUTE = "settings/chat"
|
|
||||||
const val SETTINGS_ACCOUNT_ROUTE = "settings/account"
|
|
||||||
const val SETTINGS_DATA_ROUTE = "settings/data"
|
|
||||||
const val SHARED_LINKS_ROUTE = "settings/shared-links"
|
|
||||||
const val PRESET_MANAGER_ROUTE = "settings/presets"
|
|
||||||
const val API_KEYS_ROUTE = "settings/api_keys"
|
|
||||||
|
|
||||||
fun NavGraphBuilder.settingsGraph(
|
@Serializable data object SettingsTabbed : SettingsRoute
|
||||||
|
@Serializable data object SettingsGeneral : SettingsRoute
|
||||||
|
@Serializable data object SettingsChat : SettingsRoute
|
||||||
|
@Serializable data object SettingsAccount : SettingsRoute
|
||||||
|
@Serializable data object SettingsData : SettingsRoute
|
||||||
|
@Serializable data object SharedLinks : SettingsRoute
|
||||||
|
@Serializable data object PresetManager : SettingsRoute
|
||||||
|
@Serializable data object ApiKeys : SettingsRoute
|
||||||
|
|
||||||
|
fun EntryProviderScope<NavKey>.settingsEntries(
|
||||||
|
onNavigate: (NavKey) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onNavigateBack: () -> Unit,
|
|
||||||
onNavigateToArchived: () -> Unit = {},
|
onNavigateToArchived: () -> Unit = {},
|
||||||
onNavigateToSharedLinks: () -> Unit = {},
|
|
||||||
onNavigateBackFromSharedLinks: () -> Unit = {},
|
|
||||||
onNavigateToPresets: () -> Unit = {},
|
|
||||||
onNavigateBackFromPresets: () -> Unit = {},
|
|
||||||
onNavigateToApiKeys: () -> Unit = {},
|
|
||||||
onNavigateBackFromApiKeys: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
composable(SETTINGS_TABBED_ROUTE) {
|
entry<SettingsTabbed> {
|
||||||
TabbedSettingsScreen(
|
TabbedSettingsScreen(
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onBack,
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
onNavigateToArchived = onNavigateToArchived,
|
onNavigateToArchived = onNavigateToArchived,
|
||||||
onNavigateToSharedLinks = onNavigateToSharedLinks,
|
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
|
||||||
onNavigateToPresets = onNavigateToPresets,
|
onNavigateToPresets = { onNavigate(PresetManager) },
|
||||||
onNavigateToApiKeys = onNavigateToApiKeys,
|
onNavigateToApiKeys = { onNavigate(ApiKeys) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
entry<SettingsGeneral> {
|
||||||
composable(SETTINGS_GENERAL_ROUTE) {
|
|
||||||
GeneralSettingsScreen(
|
GeneralSettingsScreen(
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(SETTINGS_CHAT_ROUTE) {
|
entry<SettingsChat> {
|
||||||
ChatSettingsScreen(
|
ChatSettingsScreen(
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onBack,
|
||||||
onNavigateToPresets = onNavigateToPresets,
|
onNavigateToPresets = { onNavigate(PresetManager) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(SETTINGS_ACCOUNT_ROUTE) {
|
entry<SettingsAccount> {
|
||||||
AccountSettingsScreen(
|
AccountSettingsScreen(
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onBack,
|
||||||
onNavigateToApiKeys = onNavigateToApiKeys,
|
onNavigateToApiKeys = { onNavigate(ApiKeys) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(SETTINGS_DATA_ROUTE) {
|
entry<SettingsData> {
|
||||||
DataSettingsScreen(
|
DataSettingsScreen(
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onBack,
|
||||||
onNavigateToArchived = onNavigateToArchived,
|
onNavigateToArchived = onNavigateToArchived,
|
||||||
onNavigateToSharedLinks = onNavigateToSharedLinks,
|
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(SHARED_LINKS_ROUTE) {
|
entry<SharedLinks> {
|
||||||
val viewModel: SettingsViewModel = koinViewModel()
|
val viewModel: SettingsViewModel = koinViewModel()
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
|
@ -87,17 +87,32 @@ fun NavGraphBuilder.settingsGraph(
|
||||||
onLoadMore = viewModel::loadMoreSharedLinks,
|
onLoadMore = viewModel::loadMoreSharedLinks,
|
||||||
onToggleVisibility = viewModel::toggleSharedLinkVisibility,
|
onToggleVisibility = viewModel::toggleSharedLinkVisibility,
|
||||||
onDelete = viewModel::deleteSharedLink,
|
onDelete = viewModel::deleteSharedLink,
|
||||||
onNavigateBack = onNavigateBackFromSharedLinks,
|
onNavigateBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(PRESET_MANAGER_ROUTE) {
|
entry<PresetManager> {
|
||||||
PresetManagerScreen(
|
PresetManagerScreen(
|
||||||
onNavigateBack = onNavigateBackFromPresets,
|
onNavigateBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(API_KEYS_ROUTE) {
|
entry<ApiKeys> {
|
||||||
ApiKeysScreen(
|
ApiKeysScreen(
|
||||||
onNavigateBack = onNavigateBackFromApiKeys,
|
onNavigateBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val settingsSerializersModule = SerializersModule {
|
||||||
|
polymorphic(NavKey::class) {
|
||||||
|
subclass(SettingsTabbed::class, SettingsTabbed.serializer())
|
||||||
|
subclass(SettingsGeneral::class, SettingsGeneral.serializer())
|
||||||
|
subclass(SettingsChat::class, SettingsChat.serializer())
|
||||||
|
subclass(SettingsAccount::class, SettingsAccount.serializer())
|
||||||
|
subclass(SettingsData::class, SettingsData.serializer())
|
||||||
|
subclass(SharedLinks::class, SharedLinks.serializer())
|
||||||
|
subclass(PresetManager::class, PresetManager.serializer())
|
||||||
|
subclass(ApiKeys::class, ApiKeys.serializer())
|
||||||
|
subclass(Memories::class, Memories.serializer())
|
||||||
|
subclass(McpServers::class, McpServers.serializer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ compose-bom = "2025.05.00"
|
||||||
compose-multiplatform = "1.11.0-beta01"
|
compose-multiplatform = "1.11.0-beta01"
|
||||||
material3 = "1.3.1"
|
material3 = "1.3.1"
|
||||||
activity-compose = "1.10.1"
|
activity-compose = "1.10.1"
|
||||||
navigation-compose = "2.9.2"
|
|
||||||
lifecycle = "2.9.0"
|
lifecycle = "2.9.0"
|
||||||
lifecycle-kmp = "2.9.0"
|
lifecycle-kmp = "2.9.0"
|
||||||
navigation-kmp = "2.9.2"
|
navigation3-kmp = "1.0.0-alpha06"
|
||||||
|
lifecycle-viewmodel-nav3-kmp = "2.10.0-alpha05"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
koin = "4.2.0"
|
koin = "4.2.0"
|
||||||
|
|
@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation
|
||||||
|
|
||||||
# Activity / Navigation
|
# Activity / Navigation
|
||||||
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
||||||
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
|
|
||||||
|
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" }
|
lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" }
|
||||||
lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" }
|
lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" }
|
||||||
navigation-compose-kmp = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation-kmp" }
|
navigation3-ui-kmp = { group = "org.jetbrains.androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3-kmp" }
|
||||||
|
lifecycle-viewmodel-navigation3-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle-viewmodel-nav3-kmp" }
|
||||||
|
|
||||||
# Koin
|
# Koin
|
||||||
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
|
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,10 @@ kotlin {
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
implementation(libs.navigation.compose.kmp)
|
implementation(libs.navigation3.ui.kmp)
|
||||||
implementation(libs.lifecycle.runtime.compose.kmp)
|
implementation(libs.lifecycle.runtime.compose.kmp)
|
||||||
implementation(libs.lifecycle.viewmodel.compose.kmp)
|
implementation(libs.lifecycle.viewmodel.compose.kmp)
|
||||||
|
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
|
||||||
implementation(libs.koin.compose)
|
implementation(libs.koin.compose)
|
||||||
implementation(libs.koin.compose.viewmodel)
|
implementation(libs.koin.compose.viewmodel)
|
||||||
implementation(libs.koin.compose.viewmodel.navigation)
|
implementation(libs.koin.compose.viewmodel.navigation)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package com.garfiec.librechat.shared.navigation
|
package com.garfiec.librechat.shared.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val sharedAppModule = module {
|
val sharedAppModule = module {
|
||||||
viewModelOf(::NavHostViewModel)
|
viewModelOf(::NavHostViewModel)
|
||||||
|
single<SerializersModule>(named("navigation")) { navigationSerializersModule }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.garfiec.librechat.shared.navigation
|
package com.garfiec.librechat.shared.navigation
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
|
@ -8,7 +7,9 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideOut
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -26,34 +27,36 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.savedstate.read
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
||||||
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
|
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
|
||||||
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
|
||||||
import com.garfiec.librechat.feature.auth.navigation.authGraph
|
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
import com.garfiec.librechat.feature.auth.navigation.authEntries
|
||||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE
|
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||||
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE
|
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||||
import com.garfiec.librechat.feature.chat.navigation.chatGraph
|
import com.garfiec.librechat.feature.chat.navigation.chatEntries
|
||||||
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
|
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
|
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
|
||||||
import com.garfiec.librechat.feature.files.navigation.filesGraph
|
import com.garfiec.librechat.feature.files.navigation.Files
|
||||||
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE
|
import com.garfiec.librechat.feature.files.navigation.filesEntries
|
||||||
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsData
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||||
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE
|
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
|
||||||
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
|
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
|
||||||
|
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import librechat_mobile.shared.generated.resources.Res
|
import librechat_mobile.shared.generated.resources.Res
|
||||||
import librechat_mobile.shared.generated.resources.dismiss
|
import librechat_mobile.shared.generated.resources.dismiss
|
||||||
|
|
@ -63,12 +66,12 @@ import librechat_mobile.shared.generated.resources.version_mismatch_title
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
/** Maps a [SettingsCategory] to its corresponding navigation route. */
|
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
|
||||||
fun SettingsCategory.toRoute(): String = when (this) {
|
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
||||||
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE
|
SettingsCategory.GENERAL -> SettingsGeneral
|
||||||
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE
|
SettingsCategory.CHAT -> SettingsChat
|
||||||
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE
|
SettingsCategory.ACCOUNT -> SettingsAccount
|
||||||
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
|
SettingsCategory.DATA -> SettingsData
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -84,43 +87,35 @@ fun LibreChatNavHost(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentDestination = navBackStackEntry?.destination
|
|
||||||
|
|
||||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
|
// Stable start key — auth redirect handled via LaunchedEffect below.
|
||||||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
|
val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
|
||||||
|
val navigator = Navigator(backStack)
|
||||||
|
|
||||||
// Track active conversation from nav back stack.
|
// Redirect to auth if not logged in (on first composition only).
|
||||||
// Use SavedState.read {} to extract nav args (KMP-compatible, no Bundle.getString).
|
LaunchedEffect(Unit) {
|
||||||
LaunchedEffect(navBackStackEntry) {
|
if (!navHostViewModel.isLoggedIn.value) {
|
||||||
val destRoute = navBackStackEntry?.destination?.route
|
navigator.navigateToAuth()
|
||||||
val conversationId = if (destRoute == CHAT_ROUTE) {
|
|
||||||
navBackStackEntry?.arguments?.read { getStringOrNull("conversationId") }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track active conversation from nav back stack
|
||||||
|
LaunchedEffect(navigator.currentRoute) {
|
||||||
|
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
|
||||||
navHostViewModel.setActiveConversation(conversationId)
|
navHostViewModel.setActiveConversation(conversationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle session expiry
|
// Handle session expiry
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
navHostViewModel.sessionExpired.collect {
|
navHostViewModel.sessionExpired.collect {
|
||||||
navController.navigate(AUTH_GRAPH_ROUTE) {
|
navigator.navigateToAuth()
|
||||||
popUpTo(0) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
|
|
||||||
|
|
||||||
PhoneLayout(
|
PhoneLayout(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
navHostViewModel = navHostViewModel,
|
navHostViewModel = navHostViewModel,
|
||||||
startDestination = startDestination,
|
|
||||||
isInAuthFlow = isInAuthFlow,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -172,10 +167,8 @@ private fun VersionMismatchDialog(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneLayout(
|
private fun PhoneLayout(
|
||||||
navController: androidx.navigation.NavHostController,
|
navigator: Navigator,
|
||||||
navHostViewModel: NavHostViewModel,
|
navHostViewModel: NavHostViewModel,
|
||||||
startDestination: String,
|
|
||||||
isInAuthFlow: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
|
@ -192,54 +185,42 @@ private fun PhoneLayout(
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
gesturesEnabled = !isInAuthFlow,
|
gesturesEnabled = !navigator.isInAuthFlow,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
SidebarScaffold(
|
SidebarScaffold(
|
||||||
viewModel = navHostViewModel,
|
viewModel = navHostViewModel,
|
||||||
onNewChat = {
|
onNewChat = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
if (navigator.currentRoute !is NewChat) {
|
||||||
if (currentRoute != NEW_CHAT_ROUTE) {
|
navigator.navigateToTopLevel(NewChat)
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConversationClick = { conversationId ->
|
onConversationClick = { conversationId ->
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigateToChat(conversationId)
|
navigator.navigateToChat(conversationId)
|
||||||
},
|
},
|
||||||
onSettingsClick = {
|
onSettingsClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
navigator.navigate(SettingsTabbed)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSettingsCategorySelected = { category ->
|
onSettingsCategorySelected = { category ->
|
||||||
navController.navigate(category.toRoute()) {
|
navigator.navigate(category.toRoute())
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onAgentsClick = {
|
onAgentsClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigate(TopLevelDestination.AGENTS.route) {
|
navigator.navigate(AgentMarketplace)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onFilesClick = {
|
onFilesClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
navController.navigate(TopLevelDestination.FILES.route) {
|
navigator.navigate(Files)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
if (!isInAuthFlow && banners.isNotEmpty()) {
|
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
|
||||||
BannerDisplay(
|
BannerDisplay(
|
||||||
banners = banners,
|
banners = banners,
|
||||||
dismissedIds = dismissedBannerIds,
|
dismissedIds = dismissedBannerIds,
|
||||||
|
|
@ -247,124 +228,88 @@ private fun PhoneLayout(
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
NavHost(
|
NavDisplay(
|
||||||
navController = navController,
|
backStack = navigator.backStack,
|
||||||
startDestination = startDestination,
|
onBack = { navigator.goBack() },
|
||||||
modifier = Modifier.fillMaxSize(),
|
transitionSpec = {
|
||||||
enterTransition = {
|
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
|
||||||
slideIntoContainer(
|
fadeOut(animationSpec = tween(150))
|
||||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
},
|
||||||
animationSpec = tween(300),
|
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() },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
exitTransition = {
|
)
|
||||||
fadeOut(animationSpec = tween(150))
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
fadeIn(
|
|
||||||
initialAlpha = 0.5f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
) + scaleIn(
|
|
||||||
initialScale = 0.92f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOut(
|
|
||||||
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
|
||||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
|
||||||
) + scaleOut(
|
|
||||||
targetScale = 0.92f,
|
|
||||||
animationSpec = tween(300, easing = LinearEasing),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
authGraph(
|
|
||||||
navController = navController,
|
|
||||||
onAuthComplete = {
|
|
||||||
navHostViewModel.onAuthComplete()
|
|
||||||
navController.navigate(CHAT_GRAPH_ROUTE) {
|
|
||||||
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
chatGraph(
|
|
||||||
navController = navController,
|
|
||||||
onOpenDrawer = {
|
|
||||||
scope.launch { drawerState.open() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
conversationsGraph(
|
|
||||||
onConversationClick = { conversationId ->
|
|
||||||
navController.navigateToChat(conversationId)
|
|
||||||
},
|
|
||||||
onNavigateToArchived = {
|
|
||||||
navController.navigate("conversations/archived")
|
|
||||||
},
|
|
||||||
onNavigateBackFromArchived = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
agentsGraph(
|
|
||||||
onAgentClick = { agentId ->
|
|
||||||
navController.navigate("agents/$agentId")
|
|
||||||
},
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onStartChat = { agentId ->
|
|
||||||
navController.navigate(NEW_CHAT_ROUTE) {
|
|
||||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreateAgent = {
|
|
||||||
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
|
|
||||||
},
|
|
||||||
onEditAgent = { agentId ->
|
|
||||||
navController.navigate("agents/editor/$agentId")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
filesGraph(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
settingsGraph(
|
|
||||||
onLogout = {
|
|
||||||
navHostViewModel.logout()
|
|
||||||
navController.navigate(AUTH_GRAPH_ROUTE) {
|
|
||||||
popUpTo(0) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
|
||||||
onNavigateToArchived = {
|
|
||||||
navController.navigate("conversations/archived") {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateToSharedLinks = {
|
|
||||||
navController.navigate(SHARED_LINKS_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromSharedLinks = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onNavigateToPresets = {
|
|
||||||
navController.navigate(PRESET_MANAGER_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromPresets = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onNavigateToApiKeys = {
|
|
||||||
navController.navigate(API_KEYS_ROUTE) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateBackFromApiKeys = {
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.garfiec.librechat.shared.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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.garfiec.librechat.shared.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,24 +0,0 @@
|
||||||
package com.garfiec.librechat.shared.navigation
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
|
||||||
import androidx.compose.material.icons.filled.Folder
|
|
||||||
import androidx.compose.material.icons.filled.Forum
|
|
||||||
import androidx.compose.material.icons.filled.SmartToy
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destinations accessible from the navigation drawer footer.
|
|
||||||
* The primary navigation is sidebar-first (drawer with conversation list),
|
|
||||||
* matching the web frontend pattern.
|
|
||||||
*/
|
|
||||||
enum class TopLevelDestination(
|
|
||||||
val route: String,
|
|
||||||
val icon: ImageVector,
|
|
||||||
val label: String,
|
|
||||||
) {
|
|
||||||
CHAT("chat_graph", Icons.AutoMirrored.Filled.Chat, "Chat"),
|
|
||||||
CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"),
|
|
||||||
AGENTS("agents", Icons.Default.SmartToy, "Agents"),
|
|
||||||
FILES("files", Icons.Default.Folder, "Files"),
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue