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
|
||||
|
||||
- **UI**: Jetpack Compose + Compose Navigation
|
||||
- **UI**: Jetpack Compose + Navigation Compose 3 (Nav 3)
|
||||
- **DI**: Koin (KMP-ready)
|
||||
- **Network**: Ktor Client (OkHttp engine)
|
||||
- **Serialization**: Kotlinx Serialization
|
||||
- **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
|
||||
|
||||
```
|
||||
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/model/ → @Serializable data classes (pure Kotlin, no Android deps)
|
||||
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
|
||||
|
||||
- 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
|
||||
- Room is a read-through cache; server is source of truth
|
||||
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ Single Activity architecture. `MainActivity` is the sole entry point.
|
|||
|
||||
## 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.
|
||||
- **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
|
||||
|
||||
`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
|
||||
|
||||
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, 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.
|
||||
Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings).
|
||||
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider.
|
||||
|
||||
## Active Conversation Tracking
|
||||
|
||||
|
|
@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
|
|||
## Auth & Session
|
||||
|
||||
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
|
||||
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise
|
||||
- `sessionExpired` flow triggers nav to auth graph with full backstack clear
|
||||
- Start destination is `NewChat` if logged in, `ServerUrl` otherwise
|
||||
- `sessionExpired` flow triggers nav to auth flow with full back stack clear
|
||||
- Drawer gestures are hidden during auth flow
|
||||
|
||||
## 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
|
||||
|
||||
### Preset Navigation
|
||||
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt`
|
||||
- `PresetManager` route registered in `SettingsNavigation.kt`
|
||||
- `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.compose")
|
||||
id("librechat.mobile.koin")
|
||||
id("librechat.kotlin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -30,7 +31,8 @@ dependencies {
|
|||
implementation(project(":feature:files"))
|
||||
|
||||
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.viewmodel)
|
||||
implementation(libs.koin.compose.viewmodel.navigation)
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
/**
|
||||
* Incremented each time a share intent arrives.
|
||||
* The NavHost observes this and either:
|
||||
* - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or
|
||||
* The NavDisplay observes this and either:
|
||||
* - 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.
|
||||
*/
|
||||
private var shareNavigationTrigger by mutableStateOf(0)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
viewModelOf(::NavHostViewModel)
|
||||
single<SerializersModule>(named("navigation")) { navigationSerializersModule }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ private val ActiveIndicatorShape = RoundedCornerShape(2.dp)
|
|||
/**
|
||||
* Stateful DrawerContent that collects its own state from the ViewModel.
|
||||
* State changes only recompose inside this composable — not the parent
|
||||
* PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content.
|
||||
* PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content.
|
||||
*/
|
||||
@Composable
|
||||
fun DrawerContent(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -9,7 +8,9 @@ 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.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -29,45 +30,48 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import com.garfiec.librechat.MainActivity
|
||||
import com.garfiec.librechat.R
|
||||
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
||||
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
||||
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
||||
import com.garfiec.librechat.feature.auth.navigation.authGraph
|
||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
||||
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_ACCOUNT_ROUTE
|
||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE
|
||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE
|
||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_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 com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
|
||||
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
|
||||
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||
import com.garfiec.librechat.feature.auth.navigation.authEntries
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatEntries
|
||||
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
|
||||
import com.garfiec.librechat.feature.files.navigation.Files
|
||||
import com.garfiec.librechat.feature.files.navigation.filesEntries
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsData
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
|
||||
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
|
||||
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
/** Maps a [SettingsCategory] to its corresponding navigation route. */
|
||||
fun SettingsCategory.toRoute(): String = when (this) {
|
||||
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE
|
||||
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE
|
||||
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE
|
||||
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
|
||||
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
|
||||
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
||||
SettingsCategory.GENERAL -> SettingsGeneral
|
||||
SettingsCategory.CHAT -> SettingsChat
|
||||
SettingsCategory.ACCOUNT -> SettingsAccount
|
||||
SettingsCategory.DATA -> SettingsData
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -79,47 +83,40 @@ fun LibreChatNavHost(
|
|||
shareNavigationTrigger: Int = 0,
|
||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||
|
||||
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
|
||||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
|
||||
// Stable start key — auth redirect handled via LaunchedEffect below.
|
||||
val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
|
||||
val navigator = Navigator(backStack)
|
||||
|
||||
// Redirect to auth if not logged in (on first composition only).
|
||||
LaunchedEffect(Unit) {
|
||||
if (!navHostViewModel.isLoggedIn.value) {
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
// Track active conversation from nav back stack
|
||||
LaunchedEffect(navBackStackEntry) {
|
||||
val route = navBackStackEntry?.destination?.route
|
||||
val conversationId = if (route == CHAT_ROUTE) {
|
||||
navBackStackEntry?.arguments?.getString("conversationId")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
LaunchedEffect(navigator.currentRoute) {
|
||||
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
|
||||
navHostViewModel.setActiveConversation(conversationId)
|
||||
}
|
||||
|
||||
// Handle session expiry
|
||||
LaunchedEffect(Unit) {
|
||||
navHostViewModel.sessionExpired.collect {
|
||||
navController.navigate(AUTH_GRAPH_ROUTE) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
|
||||
|
||||
val isTablet = windowSizeClass?.widthSizeClass?.let {
|
||||
it >= WindowWidthSizeClass.Medium
|
||||
} ?: false
|
||||
|
||||
if (isTablet) {
|
||||
TabletLayout(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = isInAuthFlow,
|
||||
deepLinkUri = deepLinkUri,
|
||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||
shareNavigationTrigger = shareNavigationTrigger,
|
||||
|
|
@ -127,10 +124,8 @@ fun LibreChatNavHost(
|
|||
)
|
||||
} else {
|
||||
PhoneLayout(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = isInAuthFlow,
|
||||
deepLinkUri = deepLinkUri,
|
||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||
shareNavigationTrigger = shareNavigationTrigger,
|
||||
|
|
@ -191,10 +186,8 @@ private fun VersionMismatchDialog(
|
|||
|
||||
@Composable
|
||||
private fun PhoneLayout(
|
||||
navController: androidx.navigation.NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: String,
|
||||
isInAuthFlow: Boolean,
|
||||
deepLinkUri: Uri?,
|
||||
onDeepLinkConsumed: () -> Unit,
|
||||
shareNavigationTrigger: Int,
|
||||
|
|
@ -202,7 +195,6 @@ private fun PhoneLayout(
|
|||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
// Banner state only -- drawer state is collected inside DrawerContent itself
|
||||
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
||||
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -212,7 +204,7 @@ private fun PhoneLayout(
|
|||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||
uri.lastPathSegment?.let { conversationId ->
|
||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
} else {
|
||||
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
|
||||
// consume the shared content reactively. Only navigate to new chat when on a
|
||||
// non-chat screen (e.g. settings, agents, files).
|
||||
// Handle share intent
|
||||
LaunchedEffect(shareNavigationTrigger) {
|
||||
if (shareNavigationTrigger > 0) {
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
|
||||
val currentRoute = navigator.currentRoute
|
||||
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||
if (!isOnChatScreen) {
|
||||
navController.navigate(NEW_CHAT_ROUTE) {
|
||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
// 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(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !isInAuthFlow,
|
||||
gesturesEnabled = !navigator.isInAuthFlow,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
SidebarScaffold(
|
||||
viewModel = navHostViewModel,
|
||||
onNewChat = {
|
||||
scope.launch { drawerState.close() }
|
||||
// Skip navigation if already on the new chat screen
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
if (currentRoute != NEW_CHAT_ROUTE) {
|
||||
navController.navigate(NEW_CHAT_ROUTE) {
|
||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
if (navigator.currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
},
|
||||
onConversationClick = { conversationId ->
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
},
|
||||
onSettingsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(SettingsTabbed)
|
||||
},
|
||||
onSettingsCategorySelected = { category ->
|
||||
// Navigate to the settings sub-page but keep the drawer open
|
||||
navController.navigate(category.toRoute()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(category.toRoute())
|
||||
},
|
||||
onAgentsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(TopLevelDestination.AGENTS.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(AgentMarketplace)
|
||||
},
|
||||
onFilesClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(TopLevelDestination.FILES.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(Files)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// No bottom bar -- drawer is the primary navigation
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
if (!isInAuthFlow && banners.isNotEmpty()) {
|
||||
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
|
||||
BannerDisplay(
|
||||
banners = banners,
|
||||
dismissedIds = dismissedBannerIds,
|
||||
|
|
@ -307,122 +277,100 @@ private fun PhoneLayout(
|
|||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
MainNavDisplay(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
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 androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
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.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.layout.Column
|
||||
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.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.MainActivity
|
||||
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.agentsGraph
|
||||
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
||||
import com.garfiec.librechat.feature.auth.navigation.authGraph
|
||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
||||
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 com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.files.navigation.Files
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||
import kotlinx.coroutines.launch
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
|
|
@ -67,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
|
|||
|
||||
@Composable
|
||||
fun TabletLayout(
|
||||
navController: NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: String,
|
||||
isInAuthFlow: Boolean,
|
||||
deepLinkUri: Uri?,
|
||||
onDeepLinkConsumed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -81,8 +57,6 @@ fun TabletLayout(
|
|||
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||
|
||||
// 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()
|
||||
|
||||
// Whether swipe gesture is enabled (from settings)
|
||||
|
|
@ -92,7 +66,6 @@ fun TabletLayout(
|
|||
val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
|
||||
|
||||
// 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 scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -107,7 +80,7 @@ fun TabletLayout(
|
|||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||
uri.lastPathSegment?.let { conversationId ->
|
||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
} else {
|
||||
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
|
||||
// consume the shared content reactively. Only navigate to new chat when on a
|
||||
// non-chat screen (e.g. settings, agents, files).
|
||||
// Handle share intent
|
||||
LaunchedEffect(shareNavigationTrigger) {
|
||||
if (shareNavigationTrigger > 0) {
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
|
||||
val currentRoute = navigator.currentRoute
|
||||
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||
if (!isOnChatScreen) {
|
||||
navController.navigate(NEW_CHAT_ROUTE) {
|
||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -148,7 +116,7 @@ fun TabletLayout(
|
|||
}
|
||||
}
|
||||
|
||||
if (!isInAuthFlow) {
|
||||
if (!navigator.isInAuthFlow) {
|
||||
val swipeModifier = if (gestureEnabled) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
|
|
@ -159,7 +127,6 @@ fun TabletLayout(
|
|||
onDragEnd = {
|
||||
val velocity = velocityTracker.calculateVelocity().x
|
||||
val currentOffset = sidebarOffset.value
|
||||
// Decide target: fling velocity wins, otherwise use 50% threshold
|
||||
val shouldOpen = when {
|
||||
velocity > FLING_VELOCITY_THRESHOLD -> true
|
||||
velocity < -FLING_VELOCITY_THRESHOLD -> false
|
||||
|
|
@ -177,7 +144,6 @@ fun TabletLayout(
|
|||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
// Snap back to the current committed state
|
||||
val target = if (isSidebarOpen) sidebarWidthPx else 0f
|
||||
scope.launch {
|
||||
sidebarOffset.animateTo(
|
||||
|
|
@ -214,37 +180,24 @@ fun TabletLayout(
|
|||
SidebarScaffold(
|
||||
viewModel = navHostViewModel,
|
||||
onNewChat = {
|
||||
// Skip navigation if already on the new chat screen
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
if (currentRoute != NEW_CHAT_ROUTE) {
|
||||
navController.navigate(NEW_CHAT_ROUTE) {
|
||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
if (navigator.currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
},
|
||||
onConversationClick = { conversationId ->
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
},
|
||||
onSettingsClick = {
|
||||
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(SettingsTabbed)
|
||||
},
|
||||
onSettingsCategorySelected = { category ->
|
||||
navController.navigate(category.toRoute()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(category.toRoute())
|
||||
},
|
||||
onAgentsClick = {
|
||||
navController.navigate(TopLevelDestination.AGENTS.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(AgentMarketplace)
|
||||
},
|
||||
onFilesClick = {
|
||||
navController.navigate(TopLevelDestination.FILES.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(Files)
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(SidebarWidth)
|
||||
|
|
@ -256,9 +209,8 @@ fun TabletLayout(
|
|||
}
|
||||
// Slot 1: Main content -- resizes to fill remaining space
|
||||
MainContent(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = false,
|
||||
banners = banners,
|
||||
dismissedBannerIds = dismissedBannerIds,
|
||||
|
|
@ -272,29 +224,24 @@ fun TabletLayout(
|
|||
val sidebarPx = SidebarWidth.roundToPx()
|
||||
val animatedPx = sidebarOffset.value.toInt()
|
||||
|
||||
// Sidebar: always measured at full 320dp width
|
||||
val sidebarPlaceable = measurables[0].measure(
|
||||
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 mainPlaceable = measurables[1].measure(
|
||||
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
|
||||
)
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
|
||||
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
|
||||
// Main content starts right after the visible sidebar portion
|
||||
mainPlaceable.placeRelative(animatedPx, 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auth flow -- no sidebar, just main content
|
||||
MainContent(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = true,
|
||||
banners = banners,
|
||||
dismissedBannerIds = dismissedBannerIds,
|
||||
|
|
@ -306,9 +253,8 @@ fun TabletLayout(
|
|||
|
||||
@Composable
|
||||
private fun MainContent(
|
||||
navController: NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: String,
|
||||
isInAuthFlow: Boolean,
|
||||
banners: List<com.garfiec.librechat.core.model.Banner>,
|
||||
dismissedBannerIds: Set<String>,
|
||||
|
|
@ -324,119 +270,11 @@ private fun MainContent(
|
|||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
MainNavDisplay(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
onOpenDrawer = onToggleDrawer,
|
||||
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 (7 total)
|
||||
## Convention Plugins (13 total)
|
||||
|
||||
### Android
|
||||
| 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.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
|
||||
| `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.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 |
|
||||
|
||||
## Critical: apply() Signature
|
||||
|
|
@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs.
|
|||
|
||||
## Feature Module Convention
|
||||
|
||||
Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose,
|
||||
koin, and serialization. They only need to add feature-specific dependencies.
|
||||
Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose,
|
||||
koin, serialization, and Nav 3. They only need to add feature-specific dependencies.
|
||||
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-viewmodel").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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
|
|||
implementation(libs.findLibrary("koin-compose").get())
|
||||
implementation(libs.findLibrary("koin-compose-viewmodel").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-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.
|
||||
|
||||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
## Navigation
|
||||
- `AGENTS_ROUTE` ("agents") -> marketplace grid
|
||||
- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view
|
||||
- Sealed interface: `AgentsRoute : NavKey` with typed route classes
|
||||
- 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
|
||||
|
||||
## Marketplace ViewModel
|
||||
|
|
|
|||
|
|
@ -14,14 +14,22 @@ val agentsModule = module {
|
|||
includes(agentsPlatformModule)
|
||||
|
||||
viewModelOf(::AgentMarketplaceViewModel)
|
||||
viewModelOf(::AgentDetailViewModel)
|
||||
viewModel {
|
||||
viewModel { params ->
|
||||
AgentDetailViewModel(
|
||||
savedStateHandle = get(),
|
||||
agentRepository = get(),
|
||||
serverDataStore = get(),
|
||||
initialAgentId = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModel { params ->
|
||||
AgentEditorViewModel(
|
||||
savedStateHandle = get(),
|
||||
agentRepository = get(),
|
||||
configRepository = get(),
|
||||
mcpRepository = get(),
|
||||
contentReader = get(),
|
||||
initialAgentId = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +1,64 @@
|
|||
package com.garfiec.librechat.feature.agents.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen
|
||||
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
|
||||
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"
|
||||
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}"
|
||||
@Serializable sealed interface AgentsRoute : NavKey
|
||||
|
||||
fun NavGraphBuilder.agentsGraph(
|
||||
onAgentClick: (String) -> Unit,
|
||||
@Serializable data object AgentMarketplace : AgentsRoute
|
||||
@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,
|
||||
onStartChat: (String) -> Unit,
|
||||
onCreateAgent: () -> Unit,
|
||||
onEditAgent: (String) -> Unit,
|
||||
) {
|
||||
composable(AGENTS_ROUTE) {
|
||||
entry<AgentMarketplace> {
|
||||
AgentMarketplaceScreen(
|
||||
onAgentClick = onAgentClick,
|
||||
onCreateAgent = onCreateAgent,
|
||||
onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||
onCreateAgent = { onNavigate(AgentEditorCreate) },
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = AGENT_DETAIL_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument("agentId") { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
entry<AgentDetail> { key ->
|
||||
AgentDetailScreen(
|
||||
onBack = onBack,
|
||||
onStartChat = onStartChat,
|
||||
onEdit = onEditAgent,
|
||||
onDuplicated = onAgentClick,
|
||||
onEdit = { agentId -> onNavigate(AgentEditorEdit(agentId = agentId)) },
|
||||
onDuplicated = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||
agentId = key.agentId,
|
||||
)
|
||||
}
|
||||
composable(AGENT_EDITOR_CREATE_ROUTE) {
|
||||
entry<AgentEditorCreate> {
|
||||
AgentEditorScreen(
|
||||
onBack = onBack,
|
||||
onSaved = { agentId -> onAgentClick(agentId) },
|
||||
onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||
agentId = null,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = AGENT_EDITOR_EDIT_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument("agentId") { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
entry<AgentEditorEdit> { key ->
|
||||
AgentEditorScreen(
|
||||
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.AgentDetailViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
|
|
@ -65,8 +66,9 @@ fun AgentDetailScreen(
|
|||
onStartChat: (String) -> Unit,
|
||||
onEdit: (String) -> Unit,
|
||||
onDuplicated: (String) -> Unit,
|
||||
agentId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgentDetailViewModel = koinViewModel(),
|
||||
viewModel: AgentDetailViewModel = koinViewModel { parametersOf(agentId) },
|
||||
) {
|
||||
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.AgentEditorViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgentEditorScreen(
|
||||
onBack: () -> Unit,
|
||||
onSaved: (String) -> Unit,
|
||||
agentId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
onDeleted: () -> Unit = onBack,
|
||||
onDuplicated: (String) -> Unit = onSaved,
|
||||
viewModel: AgentEditorViewModel = koinViewModel(),
|
||||
viewModel: AgentEditorViewModel = koinViewModel { parametersOf(agentId) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showToolDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@ class AgentDetailViewModel(
|
|||
savedStateHandle: SavedStateHandle,
|
||||
private val agentRepository: AgentRepository,
|
||||
private val serverDataStore: ServerDataStore,
|
||||
initialAgentId: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val agentId: String = checkNotNull(savedStateHandle["agentId"])
|
||||
private val agentId: String = checkNotNull(initialAgentId) { "agentId must be provided" }
|
||||
|
||||
private val _uiState = MutableStateFlow(AgentDetailUiState())
|
||||
val uiState: StateFlow<AgentDetailUiState> = _uiState.asStateFlow()
|
||||
|
|
|
|||
|
|
@ -107,9 +107,10 @@ class AgentEditorViewModel(
|
|||
private val configRepository: ConfigRepository,
|
||||
private val mcpRepository: McpRepository,
|
||||
private val contentReader: ContentReader,
|
||||
initialAgentId: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val editAgentId: String? = savedStateHandle["agentId"]
|
||||
private val editAgentId: String? = initialAgentId
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
AgentEditorUiState(
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
- **TwoFactorScreen** -- 6-digit OTP input with backup code fallback
|
||||
|
||||
## Navigation
|
||||
- Graph route: `AUTH_GRAPH_ROUTE` ("auth_graph"), start destination: `SERVER_URL_ROUTE`
|
||||
- Flow: ServerUrl -> Login -> (2FA if `tempToken` returned) -> `onAuthComplete`
|
||||
- Sealed interface: `AuthRoute : NavKey` with typed route classes
|
||||
- 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
|
||||
- `TWO_FACTOR_ROUTE` takes `{tempToken}` nav argument
|
||||
- `TwoFactor(val tempToken: String)` data class carries the nav argument directly
|
||||
|
||||
## OAuth Flow
|
||||
- `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}`
|
||||
|
|
@ -37,7 +39,7 @@
|
|||
|
||||
### Terms Screen
|
||||
- `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
|
||||
- `TermsViewModel.consumeAccepted()` resets navigation trigger
|
||||
- **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.VerifyEmailViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
|
@ -19,9 +20,28 @@ val authModule = module {
|
|||
viewModelOf(::ServerUrlViewModel)
|
||||
viewModelOf(::LoginViewModel)
|
||||
viewModelOf(::RegisterViewModel)
|
||||
viewModelOf(::VerifyEmailViewModel)
|
||||
viewModel { params ->
|
||||
VerifyEmailViewModel(
|
||||
savedStateHandle = get(),
|
||||
userRepository = get(),
|
||||
initialEmail = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModelOf(::ForgotPasswordViewModel)
|
||||
viewModelOf(::ResetPasswordViewModel)
|
||||
viewModelOf(::TwoFactorViewModel)
|
||||
viewModel { params ->
|
||||
ResetPasswordViewModel(
|
||||
savedStateHandle = get(),
|
||||
authRepository = get(),
|
||||
initialUserId = params.getOrNull(),
|
||||
initialToken = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModel { params ->
|
||||
TwoFactorViewModel(
|
||||
savedStateHandle = get(),
|
||||
authRepository = get(),
|
||||
initialTempToken = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModelOf(::TermsViewModel)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
package com.garfiec.librechat.feature.auth.navigation
|
||||
|
||||
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.core.ui.components.ScreenTransitionWrapper
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
|
||||
import com.garfiec.librechat.feature.auth.screen.LoginScreen
|
||||
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.TwoFactorScreen
|
||||
import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen
|
||||
const val AUTH_GRAPH_ROUTE = "auth_graph"
|
||||
const val SERVER_URL_ROUTE = "server_url"
|
||||
const val LOGIN_ROUTE = "login"
|
||||
const val REGISTER_ROUTE = "register"
|
||||
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}"
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
fun NavController.navigateToVerifyEmail(email: String) {
|
||||
navigate("verify_email/${encodeNavArg(email)}")
|
||||
}
|
||||
@Serializable sealed interface AuthRoute : NavKey
|
||||
|
||||
fun NavController.navigateToResetPassword(userId: String, token: String) {
|
||||
navigate("reset_password/${encodeNavArg(userId)}/${encodeNavArg(token)}")
|
||||
}
|
||||
@Serializable data object ServerUrl : AuthRoute
|
||||
@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 {
|
||||
for (c in value) {
|
||||
when {
|
||||
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,
|
||||
fun EntryProviderScope<NavKey>.authEntries(
|
||||
onNavigate: (NavKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAuthComplete: () -> Unit,
|
||||
) {
|
||||
navigation(startDestination = SERVER_URL_ROUTE, route = AUTH_GRAPH_ROUTE) {
|
||||
composable(SERVER_URL_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ServerUrlScreen(
|
||||
onServerValidated = { navController.navigate(LOGIN_ROUTE) },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(LOGIN_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = onAuthComplete,
|
||||
onNavigateToRegister = { navController.navigate(REGISTER_ROUTE) },
|
||||
onNavigateToForgotPassword = { navController.navigate(FORGOT_PASSWORD_ROUTE) },
|
||||
onNavigateToTwoFactor = { tempToken ->
|
||||
navController.navigate("two_factor/$tempToken")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(REGISTER_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
RegisterScreen(
|
||||
onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
|
||||
onNavigateToLogin = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(FORGOT_PASSWORD_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ForgotPasswordScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = TWO_FACTOR_ROUTE,
|
||||
arguments = listOf(navArgument("tempToken") { type = NavType.StringType }),
|
||||
) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
TwoFactorScreen(
|
||||
onVerified = onAuthComplete,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = VERIFY_EMAIL_ROUTE,
|
||||
arguments = listOf(navArgument("email") { type = NavType.StringType }),
|
||||
) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
VerifyEmailScreen(
|
||||
onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(TERMS_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
TermsScreen(
|
||||
onAccepted = onAuthComplete,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
entry<ServerUrl> {
|
||||
ServerUrlScreen(
|
||||
onServerValidated = { onNavigate(Login) },
|
||||
)
|
||||
}
|
||||
entry<Login> {
|
||||
LoginScreen(
|
||||
onLoginSuccess = onAuthComplete,
|
||||
onNavigateToRegister = { onNavigate(Register) },
|
||||
onNavigateToForgotPassword = { onNavigate(ForgotPassword) },
|
||||
onNavigateToTwoFactor = { tempToken ->
|
||||
onNavigate(TwoFactor(tempToken = tempToken))
|
||||
},
|
||||
)
|
||||
}
|
||||
entry<Register> {
|
||||
RegisterScreen(
|
||||
onRegistered = onBack,
|
||||
onNavigateToLogin = onBack,
|
||||
)
|
||||
}
|
||||
entry<ForgotPassword> {
|
||||
ForgotPasswordScreen(
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
entry<TwoFactor> { key ->
|
||||
TwoFactorScreen(
|
||||
onVerified = onAuthComplete,
|
||||
onBack = onBack,
|
||||
tempToken = key.tempToken,
|
||||
)
|
||||
}
|
||||
entry<VerifyEmail> { key ->
|
||||
VerifyEmailScreen(
|
||||
onVerified = onBack,
|
||||
onBack = onBack,
|
||||
email = key.email,
|
||||
)
|
||||
}
|
||||
entry<Terms> {
|
||||
TermsScreen(
|
||||
onAccepted = onAuthComplete,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
entry<ResetPassword> { key ->
|
||||
ResetPasswordScreen(
|
||||
onResetComplete = onBack,
|
||||
onBack = onBack,
|
||||
userId = key.userId,
|
||||
token = key.token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val authSerializersModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(ServerUrl::class, ServerUrl.serializer())
|
||||
subclass(Login::class, Login.serializer())
|
||||
subclass(Register::class, Register.serializer())
|
||||
subclass(ForgotPassword::class, ForgotPassword.serializer())
|
||||
subclass(TwoFactor::class, TwoFactor.serializer())
|
||||
subclass(VerifyEmail::class, VerifyEmail.serializer())
|
||||
subclass(Terms::class, Terms.serializer())
|
||||
subclass(ResetPassword::class, ResetPassword.serializer())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,14 +35,17 @@ import librechat_mobile.feature.auth.generated.resources.Res
|
|||
import librechat_mobile.feature.auth.generated.resources.*
|
||||
import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ResetPasswordScreen(
|
||||
onResetComplete: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
userId: String? = null,
|
||||
token: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ResetPasswordViewModel = koinViewModel(),
|
||||
viewModel: ResetPasswordViewModel = koinViewModel { parametersOf(userId, token) },
|
||||
) {
|
||||
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 com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TwoFactorScreen(
|
||||
onVerified: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
tempToken: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TwoFactorViewModel = koinViewModel(),
|
||||
viewModel: TwoFactorViewModel = koinViewModel { parametersOf(tempToken) },
|
||||
) {
|
||||
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 com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VerifyEmailScreen(
|
||||
onVerified: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
email: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: VerifyEmailViewModel = koinViewModel(),
|
||||
viewModel: VerifyEmailViewModel = koinViewModel { parametersOf(email) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val email = uiState.email
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ data class ResetPasswordUiState(
|
|||
class ResetPasswordViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
initialUserId: String? = null,
|
||||
initialToken: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val userId: String = savedStateHandle["userId"] ?: ""
|
||||
private val token: String = savedStateHandle["token"] ?: ""
|
||||
private val userId: String = initialUserId ?: ""
|
||||
private val token: String = initialToken ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(ResetPasswordUiState())
|
||||
val uiState: StateFlow<ResetPasswordUiState> = _uiState.asStateFlow()
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ data class TwoFactorUiState(
|
|||
class TwoFactorViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
initialTempToken: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val tempToken: String = savedStateHandle["tempToken"] ?: ""
|
||||
private val tempToken: String = initialTempToken ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(TwoFactorUiState())
|
||||
val uiState: StateFlow<TwoFactorUiState> = _uiState.asStateFlow()
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ data class VerifyEmailUiState(
|
|||
class VerifyEmailViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val userRepository: UserRepository,
|
||||
initialEmail: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
VerifyEmailUiState(email = savedStateHandle["email"] ?: ""),
|
||||
VerifyEmailUiState(email = initialEmail ?: ""),
|
||||
)
|
||||
val uiState: StateFlow<VerifyEmailUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@
|
|||
- **Active**: message list + streaming content + input area
|
||||
|
||||
## Navigation
|
||||
- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}")
|
||||
- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen
|
||||
- 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
|
||||
- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat`
|
||||
- Sealed interface: `ChatRoute : NavKey` with typed route classes
|
||||
- Routes: `NewChat` (landing), `Chat(conversationId: String? = null)`, `PromptsLibrary`, `PromptEditor(groupId: String? = null)` (all `@Serializable`)
|
||||
- Feature entries registered via `EntryProviderScope<NavKey>.chatEntries()`
|
||||
- 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
|
||||
- Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID)
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ actual val chatPlatformModule: Module = module {
|
|||
)
|
||||
}
|
||||
|
||||
viewModel {
|
||||
viewModel { params ->
|
||||
ChatViewModel(
|
||||
savedStateHandle = get(),
|
||||
initialConversationId = params.getOrNull(),
|
||||
agentRepository = get(),
|
||||
chatRepository = get(),
|
||||
messageRepository = get(),
|
||||
|
|
|
|||
|
|
@ -97,18 +97,20 @@ import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
|
|||
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
actual fun ChatScreen(
|
||||
modifier: Modifier,
|
||||
conversationId: String?,
|
||||
onConversationStarted: ((String) -> Unit)?,
|
||||
onNavigateToConversation: ((String) -> Unit)?,
|
||||
onOpenDrawer: (() -> Unit)?,
|
||||
onNavigateToPromptsLibrary: (() -> Unit)?,
|
||||
onNavigateBack: (() -> Unit)?,
|
||||
) {
|
||||
val viewModel: ChatViewModel = koinViewModel()
|
||||
val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
||||
val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle()
|
||||
|
|
@ -232,9 +234,9 @@ actual fun ChatScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// When a new conversation starts, navigate to chat/{conversationId} immediately
|
||||
// (at StreamEvent.Created) so the new_chat landing page stays clean in the back
|
||||
// stack. The new ChatViewModel at chat/{id} will resume the active stream.
|
||||
// When a new conversation starts, navigate to Chat(conversationId) immediately
|
||||
// (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.
|
||||
// onPendingNavigationHandled() resets this ViewModel to a fresh landing state.
|
||||
LaunchedEffect(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.PromptsViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val chatModule = module {
|
||||
includes(chatPlatformModule)
|
||||
viewModelOf(::PromptsViewModel)
|
||||
viewModelOf(::PromptEditorViewModel)
|
||||
viewModel { params ->
|
||||
PromptEditorViewModel(
|
||||
promptRepository = get(),
|
||||
savedStateHandle = get(),
|
||||
initialGroupId = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expect val chatPlatformModule: Module
|
||||
|
|
|
|||
|
|
@ -1,117 +1,69 @@
|
|||
package com.garfiec.librechat.feature.chat.navigation
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
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 androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
|
||||
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
|
||||
import com.garfiec.librechat.feature.chat.screen.ChatScreen
|
||||
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"
|
||||
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}"
|
||||
@Serializable sealed interface ChatRoute : NavKey
|
||||
|
||||
fun NavController.navigateToChat(conversationId: String) {
|
||||
val currentEntry = currentBackStackEntry
|
||||
val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE
|
||||
val currentDestId = currentEntry?.destination?.id
|
||||
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
|
||||
}
|
||||
}
|
||||
@Serializable data object NewChat : ChatRoute
|
||||
@Serializable data class Chat(val conversationId: String? = null) : ChatRoute
|
||||
@Serializable data object PromptsLibrary : ChatRoute
|
||||
@Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute
|
||||
|
||||
fun NavController.navigateToPromptsLibrary() {
|
||||
navigate(PROMPTS_LIBRARY_ROUTE)
|
||||
}
|
||||
|
||||
fun NavController.navigateToPromptEditor(groupId: String? = null) {
|
||||
if (groupId != null) {
|
||||
navigate("prompt_editor?groupId=$groupId")
|
||||
} else {
|
||||
navigate("prompt_editor")
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.chatGraph(
|
||||
navController: NavController,
|
||||
fun EntryProviderScope<NavKey>.chatEntries(
|
||||
onNavigate: (NavKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChat: (String) -> Unit,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
) {
|
||||
navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) {
|
||||
composable(
|
||||
route = NEW_CHAT_ROUTE,
|
||||
enterTransition = { EnterTransition.None },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { null },
|
||||
popExitTransition = { null },
|
||||
) {
|
||||
NewChatScreen(
|
||||
onConversationStarted = { conversationId ->
|
||||
navController.navigateToChat(conversationId)
|
||||
},
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = CHAT_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument("conversationId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
enterTransition = { EnterTransition.None },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { null },
|
||||
popExitTransition = { null },
|
||||
) { _ ->
|
||||
ChatScreen(
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) },
|
||||
)
|
||||
}
|
||||
composable(PROMPTS_LIBRARY_ROUTE) {
|
||||
PromptsLibraryScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onUseInChat = { promptText ->
|
||||
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() },
|
||||
)
|
||||
}
|
||||
entry<NewChat> {
|
||||
NewChatScreen(
|
||||
onConversationStarted = { conversationId ->
|
||||
onNavigateToChat(conversationId)
|
||||
},
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
|
||||
)
|
||||
}
|
||||
entry<Chat> { key ->
|
||||
ChatScreen(
|
||||
conversationId = key.conversationId,
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) },
|
||||
)
|
||||
}
|
||||
entry<PromptsLibrary> {
|
||||
PromptsLibraryScreen(
|
||||
onNavigateBack = onBack,
|
||||
onUseInChat = { _ -> onBack() },
|
||||
onNavigateToEditor = { groupId ->
|
||||
onNavigate(PromptEditor(groupId = groupId))
|
||||
},
|
||||
)
|
||||
}
|
||||
entry<PromptEditor> { key ->
|
||||
PromptEditorScreen(
|
||||
onBack = onBack,
|
||||
groupId = key.groupId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatSerializersModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(NewChat::class, NewChat.serializer())
|
||||
subclass(Chat::class, Chat.serializer())
|
||||
subclass(PromptsLibrary::class, PromptsLibrary.serializer())
|
||||
subclass(PromptEditor::class, PromptEditor.serializer())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PromptEditorScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PromptEditorViewModel = koinViewModel(),
|
||||
groupId: String? = null,
|
||||
viewModel: PromptEditorViewModel = koinViewModel { parametersOf(groupId) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
|
|
|||
|
|
@ -40,13 +40,14 @@ data class PromptEditorUiState(
|
|||
class PromptEditorViewModel(
|
||||
private val promptRepository: PromptRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
initialGroupId: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PromptEditorUiState())
|
||||
val uiState: StateFlow<PromptEditorUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
val groupId = savedStateHandle.get<String>("groupId")
|
||||
val groupId = initialGroupId
|
||||
if (groupId != null) {
|
||||
loadGroup(groupId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
|
|||
@Composable
|
||||
expect fun ChatScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
conversationId: String? = null,
|
||||
onConversationStarted: ((String) -> Unit)? = null,
|
||||
onNavigateToConversation: ((String) -> Unit)? = null,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ data class ChatUiState(
|
|||
val showDeleteConfirmation: Boolean = false,
|
||||
val duplicatedConversationId: String? = null,
|
||||
/** 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. */
|
||||
val pendingNavigationConversationId: String? = null,
|
||||
// Model comparison state
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import kotlin.uuid.Uuid
|
|||
|
||||
class ChatViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
initialConversationId: String? = null,
|
||||
private val agentRepository: AgentRepository,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val messageRepository: MessageRepository,
|
||||
|
|
@ -163,7 +164,7 @@ class ChatViewModel(
|
|||
private var titleGenerationRequested = false
|
||||
|
||||
init {
|
||||
val conversationId = savedStateHandle.get<String>("conversationId")
|
||||
val conversationId = initialConversationId
|
||||
isNewConversation = conversationId == null
|
||||
if (conversationId != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
|
|
@ -174,7 +175,7 @@ class ChatViewModel(
|
|||
loadConversationModel(conversationId)
|
||||
restoreDraft(conversationId)
|
||||
// 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.
|
||||
resumeActiveStreamIfNeeded(conversationId)
|
||||
} else {
|
||||
|
|
@ -255,7 +256,7 @@ class ChatViewModel(
|
|||
}
|
||||
|
||||
// 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 {
|
||||
val mcpServers = settingsDataStore.selectedMcpServers.first()
|
||||
val tools = settingsDataStore.enabledTools.first()
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ actual val chatPlatformModule: Module = module {
|
|||
)
|
||||
}
|
||||
|
||||
viewModel {
|
||||
viewModel { params ->
|
||||
ChatViewModel(
|
||||
savedStateHandle = get(),
|
||||
initialConversationId = params.getOrNull(),
|
||||
agentRepository = get(),
|
||||
chatRepository = get(),
|
||||
messageRepository = get(),
|
||||
|
|
|
|||
|
|
@ -81,18 +81,20 @@ import librechat_mobile.feature.chat.generated.resources.Res
|
|||
import librechat_mobile.feature.chat.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
actual fun ChatScreen(
|
||||
modifier: Modifier,
|
||||
conversationId: String?,
|
||||
onConversationStarted: ((String) -> Unit)?,
|
||||
onNavigateToConversation: ((String) -> Unit)?,
|
||||
onOpenDrawer: (() -> Unit)?,
|
||||
onNavigateToPromptsLibrary: (() -> Unit)?,
|
||||
onNavigateBack: (() -> Unit)?,
|
||||
) {
|
||||
val viewModel: ChatViewModel = koinViewModel()
|
||||
val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
||||
val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# feature:conversations
|
||||
|
||||
## 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
|
||||
- Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)`
|
||||
|
|
|
|||
|
|
@ -1,32 +1,40 @@
|
|||
package com.garfiec.librechat.feature.conversations.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen
|
||||
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"
|
||||
const val ARCHIVED_ROUTE = "conversations/archived"
|
||||
@Serializable sealed interface ConversationsRoute : NavKey
|
||||
|
||||
fun NavGraphBuilder.conversationsGraph(
|
||||
@Serializable data object Conversations : ConversationsRoute
|
||||
@Serializable data object ArchivedConversations : ConversationsRoute
|
||||
|
||||
fun EntryProviderScope<NavKey>.conversationsEntries(
|
||||
onConversationClick: (String) -> Unit,
|
||||
onNavigateToArchived: () -> Unit = {},
|
||||
onNavigateBackFromArchived: () -> Unit = {},
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
composable(CONVERSATIONS_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ConversationListScreen(
|
||||
onConversationClick = onConversationClick,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
)
|
||||
}
|
||||
entry<Conversations> {
|
||||
ConversationListScreen(
|
||||
onConversationClick = onConversationClick,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
)
|
||||
}
|
||||
composable(ARCHIVED_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ArchivedConversationsScreen(
|
||||
onNavigateBack = onNavigateBackFromArchived,
|
||||
)
|
||||
}
|
||||
entry<ArchivedConversations> {
|
||||
ArchivedConversationsScreen(
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val conversationsSerializersModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(Conversations::class, Conversations.serializer())
|
||||
subclass(ArchivedConversations::class, ArchivedConversations.serializer())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# feature:files
|
||||
|
||||
## 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
|
||||
`FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver).
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
package com.garfiec.librechat.feature.files.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
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,
|
||||
) {
|
||||
composable(FILES_ROUTE) {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
FilesScreen(onBack = onBack)
|
||||
}
|
||||
entry<Files> {
|
||||
FilesScreen(onBack = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
val filesSerializersModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(Files::class, Files.serializer())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# feature:settings
|
||||
|
||||
## 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
|
||||
- **Account**: displays user profile from `UserApi.getUser()`
|
||||
|
|
@ -46,14 +46,14 @@
|
|||
- `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`)
|
||||
- `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`)
|
||||
- `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**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations
|
||||
- **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field)
|
||||
|
||||
### API Keys
|
||||
- `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
|
||||
- **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
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
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(
|
||||
onNavigateBack: () -> Unit,
|
||||
fun EntryProviderScope<NavKey>.mcpServersEntry(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
composable(MCP_SERVERS_ROUTE) {
|
||||
entry<McpServers> {
|
||||
McpServersScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
package com.garfiec.librechat.feature.settings.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
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(
|
||||
onNavigateBack: () -> Unit,
|
||||
fun EntryProviderScope<NavKey>.memoriesEntry(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
composable(MEMORIES_ROUTE) {
|
||||
entry<Memories> {
|
||||
MemoriesScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.garfiec.librechat.feature.settings.navigation
|
|||
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen
|
||||
import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen
|
||||
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.TabbedSettingsScreen
|
||||
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
|
||||
|
||||
const val SETTINGS_TABBED_ROUTE = "settings"
|
||||
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"
|
||||
@Serializable sealed interface SettingsRoute : NavKey
|
||||
|
||||
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,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToArchived: () -> Unit = {},
|
||||
onNavigateToSharedLinks: () -> Unit = {},
|
||||
onNavigateBackFromSharedLinks: () -> Unit = {},
|
||||
onNavigateToPresets: () -> Unit = {},
|
||||
onNavigateBackFromPresets: () -> Unit = {},
|
||||
onNavigateToApiKeys: () -> Unit = {},
|
||||
onNavigateBackFromApiKeys: () -> Unit = {},
|
||||
) {
|
||||
composable(SETTINGS_TABBED_ROUTE) {
|
||||
entry<SettingsTabbed> {
|
||||
TabbedSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
onLogout = onLogout,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
onNavigateToSharedLinks = onNavigateToSharedLinks,
|
||||
onNavigateToPresets = onNavigateToPresets,
|
||||
onNavigateToApiKeys = onNavigateToApiKeys,
|
||||
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
|
||||
onNavigateToPresets = { onNavigate(PresetManager) },
|
||||
onNavigateToApiKeys = { onNavigate(ApiKeys) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(SETTINGS_GENERAL_ROUTE) {
|
||||
entry<SettingsGeneral> {
|
||||
GeneralSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
composable(SETTINGS_CHAT_ROUTE) {
|
||||
entry<SettingsChat> {
|
||||
ChatSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToPresets = onNavigateToPresets,
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToPresets = { onNavigate(PresetManager) },
|
||||
)
|
||||
}
|
||||
composable(SETTINGS_ACCOUNT_ROUTE) {
|
||||
entry<SettingsAccount> {
|
||||
AccountSettingsScreen(
|
||||
onLogout = onLogout,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToApiKeys = onNavigateToApiKeys,
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToApiKeys = { onNavigate(ApiKeys) },
|
||||
)
|
||||
}
|
||||
composable(SETTINGS_DATA_ROUTE) {
|
||||
entry<SettingsData> {
|
||||
DataSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
onNavigateToSharedLinks = onNavigateToSharedLinks,
|
||||
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
|
||||
)
|
||||
}
|
||||
composable(SHARED_LINKS_ROUTE) {
|
||||
entry<SharedLinks> {
|
||||
val viewModel: SettingsViewModel = koinViewModel()
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -87,17 +87,32 @@ fun NavGraphBuilder.settingsGraph(
|
|||
onLoadMore = viewModel::loadMoreSharedLinks,
|
||||
onToggleVisibility = viewModel::toggleSharedLinkVisibility,
|
||||
onDelete = viewModel::deleteSharedLink,
|
||||
onNavigateBack = onNavigateBackFromSharedLinks,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
composable(PRESET_MANAGER_ROUTE) {
|
||||
entry<PresetManager> {
|
||||
PresetManagerScreen(
|
||||
onNavigateBack = onNavigateBackFromPresets,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
composable(API_KEYS_ROUTE) {
|
||||
entry<ApiKeys> {
|
||||
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"
|
||||
material3 = "1.3.1"
|
||||
activity-compose = "1.10.1"
|
||||
navigation-compose = "2.9.2"
|
||||
lifecycle = "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
|
||||
koin = "4.2.0"
|
||||
|
|
@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation
|
|||
|
||||
# Activity / Navigation
|
||||
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-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-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" }
|
||||
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-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
|
||||
|
|
|
|||
|
|
@ -42,9 +42,10 @@ kotlin {
|
|||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.navigation.compose.kmp)
|
||||
implementation(libs.navigation3.ui.kmp)
|
||||
implementation(libs.lifecycle.runtime.compose.kmp)
|
||||
implementation(libs.lifecycle.viewmodel.compose.kmp)
|
||||
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
implementation(libs.koin.compose.viewmodel.navigation)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package com.garfiec.librechat.shared.navigation
|
||||
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val sharedAppModule = module {
|
||||
viewModelOf(::NavHostViewModel)
|
||||
single<SerializersModule>(named("navigation")) { navigationSerializersModule }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.garfiec.librechat.shared.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -8,7 +7,9 @@ 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.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -26,34 +27,36 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.savedstate.read
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
||||
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
||||
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
||||
import com.garfiec.librechat.feature.auth.navigation.authGraph
|
||||
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
||||
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_ACCOUNT_ROUTE
|
||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE
|
||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE
|
||||
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_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 com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
|
||||
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
|
||||
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||
import com.garfiec.librechat.feature.auth.navigation.authEntries
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatEntries
|
||||
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
|
||||
import com.garfiec.librechat.feature.files.navigation.Files
|
||||
import com.garfiec.librechat.feature.files.navigation.filesEntries
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsData
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
|
||||
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
|
||||
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
|
||||
import kotlinx.coroutines.launch
|
||||
import librechat_mobile.shared.generated.resources.Res
|
||||
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.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
/** Maps a [SettingsCategory] to its corresponding navigation route. */
|
||||
fun SettingsCategory.toRoute(): String = when (this) {
|
||||
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE
|
||||
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE
|
||||
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE
|
||||
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
|
||||
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
|
||||
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
||||
SettingsCategory.GENERAL -> SettingsGeneral
|
||||
SettingsCategory.CHAT -> SettingsChat
|
||||
SettingsCategory.ACCOUNT -> SettingsAccount
|
||||
SettingsCategory.DATA -> SettingsData
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,43 +87,35 @@ fun LibreChatNavHost(
|
|||
modifier: Modifier = Modifier,
|
||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||
|
||||
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
|
||||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
|
||||
// Stable start key — auth redirect handled via LaunchedEffect below.
|
||||
val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
|
||||
val navigator = Navigator(backStack)
|
||||
|
||||
// Track active conversation from nav back stack.
|
||||
// Use SavedState.read {} to extract nav args (KMP-compatible, no Bundle.getString).
|
||||
LaunchedEffect(navBackStackEntry) {
|
||||
val destRoute = navBackStackEntry?.destination?.route
|
||||
val conversationId = if (destRoute == CHAT_ROUTE) {
|
||||
navBackStackEntry?.arguments?.read { getStringOrNull("conversationId") }
|
||||
} else {
|
||||
null
|
||||
// Redirect to auth if not logged in (on first composition only).
|
||||
LaunchedEffect(Unit) {
|
||||
if (!navHostViewModel.isLoggedIn.value) {
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
// Track active conversation from nav back stack
|
||||
LaunchedEffect(navigator.currentRoute) {
|
||||
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
|
||||
navHostViewModel.setActiveConversation(conversationId)
|
||||
}
|
||||
|
||||
// Handle session expiry
|
||||
LaunchedEffect(Unit) {
|
||||
navHostViewModel.sessionExpired.collect {
|
||||
navController.navigate(AUTH_GRAPH_ROUTE) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
|
||||
|
||||
PhoneLayout(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = isInAuthFlow,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
|
|
@ -172,10 +167,8 @@ private fun VersionMismatchDialog(
|
|||
|
||||
@Composable
|
||||
private fun PhoneLayout(
|
||||
navController: androidx.navigation.NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: String,
|
||||
isInAuthFlow: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
|
|
@ -192,54 +185,42 @@ private fun PhoneLayout(
|
|||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !isInAuthFlow,
|
||||
gesturesEnabled = !navigator.isInAuthFlow,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
SidebarScaffold(
|
||||
viewModel = navHostViewModel,
|
||||
onNewChat = {
|
||||
scope.launch { drawerState.close() }
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
if (currentRoute != NEW_CHAT_ROUTE) {
|
||||
navController.navigate(NEW_CHAT_ROUTE) {
|
||||
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
if (navigator.currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
},
|
||||
onConversationClick = { conversationId ->
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
},
|
||||
onSettingsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(SettingsTabbed)
|
||||
},
|
||||
onSettingsCategorySelected = { category ->
|
||||
navController.navigate(category.toRoute()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(category.toRoute())
|
||||
},
|
||||
onAgentsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(TopLevelDestination.AGENTS.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(AgentMarketplace)
|
||||
},
|
||||
onFilesClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(TopLevelDestination.FILES.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(Files)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
if (!isInAuthFlow && banners.isNotEmpty()) {
|
||||
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
|
||||
BannerDisplay(
|
||||
banners = banners,
|
||||
dismissedIds = dismissedBannerIds,
|
||||
|
|
@ -247,124 +228,88 @@ private fun PhoneLayout(
|
|||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = tween(300),
|
||||
NavDisplay(
|
||||
backStack = navigator.backStack,
|
||||
onBack = { navigator.goBack() },
|
||||
transitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
popTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
|
||||
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
targetOffsetX = { (it * 0.15f).toInt() },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
))
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
authEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onAuthComplete = {
|
||||
navHostViewModel.onAuthComplete()
|
||||
navigator.navigateToChat()
|
||||
},
|
||||
)
|
||||
chatEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onNavigateToChat = { navigator.navigateToChat(it) },
|
||||
onOpenDrawer = {
|
||||
scope.launch { drawerState.open() }
|
||||
},
|
||||
)
|
||||
conversationsEntries(
|
||||
onConversationClick = { navigator.navigateToChat(it) },
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
agentsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onStartChat = { _ ->
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
},
|
||||
)
|
||||
filesEntries(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
settingsEntries(
|
||||
onNavigate = { navigator.navigate(it) },
|
||||
onBack = { navigator.goBack() },
|
||||
onLogout = {
|
||||
navHostViewModel.logout()
|
||||
navigator.navigateToAuth()
|
||||
},
|
||||
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
|
||||
)
|
||||
memoriesEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
mcpServersEntry(
|
||||
onBack = { navigator.goBack() },
|
||||
)
|
||||
},
|
||||
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