refactor: convert navigation to type-safe routes with polymorphic serialization

This commit is contained in:
Garfie 2026-04-03 20:10:52 -05:00
parent 9be9a57b9b
commit 2756efa812
16 changed files with 412 additions and 278 deletions

View file

@ -2,6 +2,7 @@ plugins {
id("librechat.mobile.application")
id("librechat.mobile.compose")
id("librechat.mobile.koin")
id("librechat.kotlin.serialization")
}
android {

View file

@ -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 }
}

View file

@ -32,42 +32,58 @@ 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.NavDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.toRoute
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlin.reflect.KClass
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.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
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.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
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.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.Files
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.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
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.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
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
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
/** 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
@ -85,14 +101,13 @@ fun LibreChatNavHost(
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) ||
currentDestination?.parent.isRoute(AuthRoute::class)
// Track active conversation from nav back stack
LaunchedEffect(navBackStackEntry) {
val route = navBackStackEntry?.destination?.route
val conversationId = if (route == CHAT_ROUTE) {
navBackStackEntry?.arguments?.getString("conversationId")
val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
navBackStackEntry?.toRoute<Chat>()?.conversationId
} else {
null
}
@ -102,13 +117,13 @@ fun LibreChatNavHost(
// Handle session expiry
LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect {
navController.navigate(AUTH_GRAPH_ROUTE) {
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
}
}
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium
@ -193,7 +208,7 @@ private fun VersionMismatchDialog(
private fun PhoneLayout(
navController: androidx.navigation.NavHostController,
navHostViewModel: NavHostViewModel,
startDestination: String,
startDestination: Any,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
@ -227,11 +242,12 @@ private fun PhoneLayout(
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
val currentDest = navController.currentBackStackEntry?.destination
val isOnChatScreen = currentDest.isRoute(Chat::class) ||
currentDest.isRoute(NewChat::class)
if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
}
@ -257,10 +273,10 @@ private fun PhoneLayout(
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 }
val currentDest = navController.currentBackStackEntry?.destination
if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
}
@ -271,7 +287,7 @@ private fun PhoneLayout(
},
onSettingsClick = {
scope.launch { drawerState.close() }
navController.navigate(SETTINGS_TABBED_ROUTE) {
navController.navigate(SettingsTabbed) {
launchSingleTop = true
}
},
@ -283,13 +299,13 @@ private fun PhoneLayout(
},
onAgentsClick = {
scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.AGENTS.route) {
navController.navigate(AgentMarketplace) {
launchSingleTop = true
}
},
onFilesClick = {
scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.FILES.route) {
navController.navigate(Files) {
launchSingleTop = true
}
},
@ -343,8 +359,8 @@ private fun PhoneLayout(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
navController.navigate(NewChat) {
popUpTo<AuthRoute> { inclusive = true }
}
},
)
@ -359,7 +375,7 @@ private fun PhoneLayout(
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate("conversations/archived")
navController.navigate(ArchivedConversations)
},
onNavigateBackFromArchived = {
navController.popBackStack()
@ -367,38 +383,38 @@ private fun PhoneLayout(
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate("agents/$agentId")
navController.navigate(AgentDetail(agentId = agentId))
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
navController.navigate(AgentEditorCreate)
},
onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId")
navController.navigate(AgentEditorEdit(agentId = agentId))
},
)
filesGraph()
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) {
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate("conversations/archived") {
navController.navigate(ArchivedConversations) {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) {
navController.navigate(SharedLinks) {
launchSingleTop = true
}
},
@ -406,7 +422,7 @@ private fun PhoneLayout(
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) {
navController.navigate(PresetManager) {
launchSingleTop = true
}
},
@ -414,7 +430,7 @@ private fun PhoneLayout(
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) {
navController.navigate(ApiKeys) {
launchSingleTop = true
}
},

View file

@ -0,0 +1,23 @@
package com.garfiec.librechat.navigation
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.
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
*/
val navigationSerializersModule: SerializersModule =
authSerializersModule +
chatSerializersModule +
conversationsSerializersModule +
agentsSerializersModule +
filesSerializersModule +
settingsSerializersModule

View file

@ -37,29 +37,43 @@ 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.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import kotlin.reflect.KClass
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.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
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.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
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.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.Files
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.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
private val SidebarWidth = 320.dp
/** Velocity (px/s) above which a fling commits the gesture regardless of position. */
@ -69,7 +83,7 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
fun TabletLayout(
navController: NavHostController,
navHostViewModel: NavHostViewModel,
startDestination: String,
startDestination: Any,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
@ -122,11 +136,12 @@ fun TabletLayout(
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
val currentDest = navController.currentBackStackEntry?.destination
val isOnChatScreen = currentDest.isRoute(Chat::class) ||
currentDest.isRoute(NewChat::class)
if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
}
@ -215,10 +230,10 @@ fun TabletLayout(
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 }
val currentDest = navController.currentBackStackEntry?.destination
if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
}
@ -227,7 +242,7 @@ fun TabletLayout(
navController.navigateToChat(conversationId)
},
onSettingsClick = {
navController.navigate(SETTINGS_TABBED_ROUTE) {
navController.navigate(SettingsTabbed) {
launchSingleTop = true
}
},
@ -237,12 +252,12 @@ fun TabletLayout(
}
},
onAgentsClick = {
navController.navigate(TopLevelDestination.AGENTS.route) {
navController.navigate(AgentMarketplace) {
launchSingleTop = true
}
},
onFilesClick = {
navController.navigate(TopLevelDestination.FILES.route) {
navController.navigate(Files) {
launchSingleTop = true
}
},
@ -308,7 +323,7 @@ fun TabletLayout(
private fun MainContent(
navController: NavHostController,
navHostViewModel: NavHostViewModel,
startDestination: String,
startDestination: Any,
isInAuthFlow: Boolean,
banners: List<com.garfiec.librechat.core.model.Banner>,
dismissedBannerIds: Set<String>,
@ -360,8 +375,8 @@ private fun MainContent(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
navController.navigate(NewChat) {
popUpTo<AuthRoute> { inclusive = true }
}
},
)
@ -374,7 +389,7 @@ private fun MainContent(
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate("conversations/archived")
navController.navigate(ArchivedConversations)
},
onNavigateBackFromArchived = {
navController.popBackStack()
@ -382,38 +397,38 @@ private fun MainContent(
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate("agents/$agentId")
navController.navigate(AgentDetail(agentId = agentId))
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
navController.navigate(AgentEditorCreate)
},
onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId")
navController.navigate(AgentEditorEdit(agentId = agentId))
},
)
filesGraph()
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) {
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate("conversations/archived") {
navController.navigate(ArchivedConversations) {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) {
navController.navigate(SharedLinks) {
launchSingleTop = true
}
},
@ -421,7 +436,7 @@ private fun MainContent(
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) {
navController.navigate(PresetManager) {
launchSingleTop = true
}
},
@ -429,7 +444,7 @@ private fun MainContent(
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) {
navController.navigate(ApiKeys) {
launchSingleTop = true
}
},

View file

@ -1,17 +1,21 @@
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 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
@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 NavGraphBuilder.agentsGraph(
onAgentClick: (String) -> Unit,
@ -20,19 +24,14 @@ fun NavGraphBuilder.agentsGraph(
onCreateAgent: () -> Unit,
onEditAgent: (String) -> Unit,
) {
composable(AGENTS_ROUTE) {
composable<AgentMarketplace> {
AgentMarketplaceScreen(
onAgentClick = onAgentClick,
onCreateAgent = onCreateAgent,
onBack = onBack,
)
}
composable(
route = AGENT_DETAIL_ROUTE,
arguments = listOf(
navArgument("agentId") { type = NavType.StringType },
),
) {
composable<AgentDetail> {
AgentDetailScreen(
onBack = onBack,
onStartChat = onStartChat,
@ -40,21 +39,25 @@ fun NavGraphBuilder.agentsGraph(
onDuplicated = onAgentClick,
)
}
composable(AGENT_EDITOR_CREATE_ROUTE) {
composable<AgentEditorCreate> {
AgentEditorScreen(
onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) },
)
}
composable(
route = AGENT_EDITOR_EDIT_ROUTE,
arguments = listOf(
navArgument("agentId") { type = NavType.StringType },
),
) {
composable<AgentEditorEdit> {
AgentEditorScreen(
onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) },
)
}
}
val agentsSerializersModule = SerializersModule {
polymorphic(AgentsRoute::class) {
subclass(AgentMarketplace::class, AgentMarketplace.serializer())
subclass(AgentDetail::class, AgentDetail.serializer())
subclass(AgentEditorCreate::class, AgentEditorCreate.serializer())
subclass(AgentEditorEdit::class, AgentEditorEdit.serializer())
}
}

View file

@ -2,9 +2,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 com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
@ -15,84 +13,70 @@ 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
@Serializable sealed interface AuthRoute
@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
fun NavController.navigateToVerifyEmail(email: String) {
navigate("verify_email/${encodeNavArg(email)}")
navigate(VerifyEmail(email = email))
}
fun NavController.navigateToResetPassword(userId: String, token: String) {
navigate("reset_password/${encodeNavArg(userId)}/${encodeNavArg(token)}")
}
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'),
)
}
}
}
}
navigate(ResetPassword(userId = userId, token = token))
}
fun NavGraphBuilder.authGraph(
navController: NavController,
onAuthComplete: () -> Unit,
) {
navigation(startDestination = SERVER_URL_ROUTE, route = AUTH_GRAPH_ROUTE) {
composable(SERVER_URL_ROUTE) {
navigation<AuthRoute>(startDestination = ServerUrl::class) {
composable<ServerUrl> {
ScreenTransitionWrapper(transition) {
ServerUrlScreen(
onServerValidated = { navController.navigate(LOGIN_ROUTE) },
onServerValidated = { navController.navigate(Login) },
)
}
}
composable(LOGIN_ROUTE) {
composable<Login> {
ScreenTransitionWrapper(transition) {
LoginScreen(
onLoginSuccess = onAuthComplete,
onNavigateToRegister = { navController.navigate(REGISTER_ROUTE) },
onNavigateToForgotPassword = { navController.navigate(FORGOT_PASSWORD_ROUTE) },
onNavigateToRegister = { navController.navigate(Register) },
onNavigateToForgotPassword = { navController.navigate(ForgotPassword) },
onNavigateToTwoFactor = { tempToken ->
navController.navigate("two_factor/$tempToken")
navController.navigate(TwoFactor(tempToken = tempToken))
},
)
}
}
composable(REGISTER_ROUTE) {
composable<Register> {
ScreenTransitionWrapper(transition) {
RegisterScreen(
onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
onRegistered = { navController.popBackStack<Login>(inclusive = false) },
onNavigateToLogin = { navController.popBackStack() },
)
}
}
composable(FORGOT_PASSWORD_ROUTE) {
composable<ForgotPassword> {
ScreenTransitionWrapper(transition) {
ForgotPasswordScreen(
onBack = { navController.popBackStack() },
)
}
}
composable(
route = TWO_FACTOR_ROUTE,
arguments = listOf(navArgument("tempToken") { type = NavType.StringType }),
) {
composable<TwoFactor> {
ScreenTransitionWrapper(transition) {
TwoFactorScreen(
onVerified = onAuthComplete,
@ -100,18 +84,15 @@ fun NavGraphBuilder.authGraph(
)
}
}
composable(
route = VERIFY_EMAIL_ROUTE,
arguments = listOf(navArgument("email") { type = NavType.StringType }),
) {
composable<VerifyEmail> {
ScreenTransitionWrapper(transition) {
VerifyEmailScreen(
onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
onVerified = { navController.popBackStack<Login>(inclusive = false) },
onBack = { navController.popBackStack() },
)
}
}
composable(TERMS_ROUTE) {
composable<Terms> {
ScreenTransitionWrapper(transition) {
TermsScreen(
onAccepted = onAuthComplete,
@ -119,19 +100,26 @@ fun NavGraphBuilder.authGraph(
)
}
}
composable(
route = RESET_PASSWORD_ROUTE,
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("token") { type = NavType.StringType },
),
) {
composable<ResetPassword> {
ScreenTransitionWrapper(transition) {
ResetPasswordScreen(
onResetComplete = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) },
onResetComplete = { navController.popBackStack<Login>(inclusive = false) },
onBack = { navController.popBackStack() },
)
}
}
}
}
val authSerializersModule = SerializersModule {
polymorphic(AuthRoute::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())
}
}

View file

@ -3,27 +3,38 @@ package com.garfiec.librechat.feature.chat.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navigation
import kotlin.reflect.KClass
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
@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
/** Checks if this destination's route matches the given typed route class. */
internal fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
fun NavController.navigateToChat(conversationId: String) {
val currentEntry = currentBackStackEntry
val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE
val isCurrentlyInChat = currentEntry?.destination.isRoute(Chat::class)
val currentDestId = currentEntry?.destination?.id
navigate("chat/$conversationId") {
navigate(Chat(conversationId = conversationId)) {
if (isCurrentlyInChat && currentDestId != null) {
// Already viewing a chat -- replace it so switching chats
// doesn't stack entries. Back will go to whatever was
@ -36,24 +47,19 @@ fun NavController.navigateToChat(conversationId: String) {
}
fun NavController.navigateToPromptsLibrary() {
navigate(PROMPTS_LIBRARY_ROUTE)
navigate(PromptsLibrary)
}
fun NavController.navigateToPromptEditor(groupId: String? = null) {
if (groupId != null) {
navigate("prompt_editor?groupId=$groupId")
} else {
navigate("prompt_editor")
}
navigate(PromptEditor(groupId = groupId))
}
fun NavGraphBuilder.chatGraph(
navController: NavController,
onOpenDrawer: (() -> Unit)? = null,
) {
navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) {
composable(
route = NEW_CHAT_ROUTE,
navigation<ChatRoute>(startDestination = NewChat::class) {
composable<NewChat>(
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { null },
@ -67,15 +73,7 @@ fun NavGraphBuilder.chatGraph(
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
)
}
composable(
route = CHAT_ROUTE,
arguments = listOf(
navArgument("conversationId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
composable<Chat>(
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { null },
@ -88,7 +86,7 @@ fun NavGraphBuilder.chatGraph(
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) },
)
}
composable(PROMPTS_LIBRARY_ROUTE) {
composable<PromptsLibrary> {
PromptsLibraryScreen(
onNavigateBack = { navController.popBackStack() },
onUseInChat = { promptText ->
@ -99,19 +97,19 @@ fun NavGraphBuilder.chatGraph(
},
)
}
composable(
route = PROMPT_EDITOR_ROUTE,
arguments = listOf(
navArgument("groupId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
composable<PromptEditor> {
PromptEditorScreen(
onBack = { navController.popBackStack() },
)
}
}
}
val chatSerializersModule = SerializersModule {
polymorphic(ChatRoute::class) {
subclass(NewChat::class, NewChat.serializer())
subclass(Chat::class, Chat.serializer())
subclass(PromptsLibrary::class, PromptsLibrary.serializer())
subclass(PromptEditor::class, PromptEditor.serializer())
}
}

View file

@ -5,16 +5,22 @@ import androidx.navigation.compose.composable
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
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
@Serializable data object Conversations : ConversationsRoute
@Serializable data object ArchivedConversations : ConversationsRoute
fun NavGraphBuilder.conversationsGraph(
onConversationClick: (String) -> Unit,
onNavigateToArchived: () -> Unit = {},
onNavigateBackFromArchived: () -> Unit = {},
) {
composable(CONVERSATIONS_ROUTE) {
composable<Conversations> {
ScreenTransitionWrapper(transition) {
ConversationListScreen(
onConversationClick = onConversationClick,
@ -22,7 +28,7 @@ fun NavGraphBuilder.conversationsGraph(
)
}
}
composable(ARCHIVED_ROUTE) {
composable<ArchivedConversations> {
ScreenTransitionWrapper(transition) {
ArchivedConversationsScreen(
onNavigateBack = onNavigateBackFromArchived,
@ -30,3 +36,10 @@ fun NavGraphBuilder.conversationsGraph(
}
}
}
val conversationsSerializersModule = SerializersModule {
polymorphic(ConversationsRoute::class) {
subclass(Conversations::class, Conversations.serializer())
subclass(ArchivedConversations::class, ArchivedConversations.serializer())
}
}

View file

@ -4,15 +4,27 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
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
@Serializable data object Files : FilesRoute
fun NavGraphBuilder.filesGraph(
onBack: (() -> Unit)? = null,
) {
composable(FILES_ROUTE) {
composable<Files> {
ScreenTransitionWrapper(transition) {
FilesScreen(onBack = onBack)
}
}
}
val filesSerializersModule = SerializersModule {
polymorphic(FilesRoute::class) {
subclass(Files::class, Files.serializer())
}
}

View file

@ -3,13 +3,14 @@ package com.garfiec.librechat.feature.settings.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
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,
) {
composable(MCP_SERVERS_ROUTE) {
composable<McpServers> {
McpServersScreen(
onNavigateBack = onNavigateBack,
)

View file

@ -3,13 +3,14 @@ package com.garfiec.librechat.feature.settings.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
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,
) {
composable(MEMORIES_ROUTE) {
composable<Memories> {
MemoriesScreen(
onNavigateBack = onNavigateBack,
)

View file

@ -13,16 +13,22 @@ 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
@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 NavGraphBuilder.settingsGraph(
onLogout: () -> Unit,
@ -35,7 +41,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToApiKeys: () -> Unit = {},
onNavigateBackFromApiKeys: () -> Unit = {},
) {
composable(SETTINGS_TABBED_ROUTE) {
composable<SettingsTabbed> {
TabbedSettingsScreen(
onNavigateBack = onNavigateBack,
onLogout = onLogout,
@ -46,32 +52,32 @@ fun NavGraphBuilder.settingsGraph(
)
}
composable(SETTINGS_GENERAL_ROUTE) {
composable<SettingsGeneral> {
GeneralSettingsScreen(
onNavigateBack = onNavigateBack,
)
}
composable(SETTINGS_CHAT_ROUTE) {
composable<SettingsChat> {
ChatSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateToPresets = onNavigateToPresets,
)
}
composable(SETTINGS_ACCOUNT_ROUTE) {
composable<SettingsAccount> {
AccountSettingsScreen(
onLogout = onLogout,
onNavigateBack = onNavigateBack,
onNavigateToApiKeys = onNavigateToApiKeys,
)
}
composable(SETTINGS_DATA_ROUTE) {
composable<SettingsData> {
DataSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks,
)
}
composable(SHARED_LINKS_ROUTE) {
composable<SharedLinks> {
val viewModel: SettingsViewModel = koinViewModel()
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
@ -90,14 +96,29 @@ fun NavGraphBuilder.settingsGraph(
onNavigateBack = onNavigateBackFromSharedLinks,
)
}
composable(PRESET_MANAGER_ROUTE) {
composable<PresetManager> {
PresetManagerScreen(
onNavigateBack = onNavigateBackFromPresets,
)
}
composable(API_KEYS_ROUTE) {
composable<ApiKeys> {
ApiKeysScreen(
onNavigateBack = onNavigateBackFromApiKeys,
)
}
}
val settingsSerializersModule = SerializersModule {
polymorphic(SettingsRoute::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())
}
}

View file

@ -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 }
}

View file

@ -29,30 +29,39 @@ 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.NavDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.toRoute
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlin.reflect.KClass
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.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
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.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
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.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.Files
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.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
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.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch
import librechat_mobile.shared.generated.resources.Res
@ -63,12 +72,18 @@ 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
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
/** 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
}
/**
@ -90,15 +105,13 @@ fun LibreChatNavHost(
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) ||
currentDestination?.parent.isRoute(AuthRoute::class)
// Track active conversation from nav back stack.
// Use SavedState.read {} to extract nav args (KMP-compatible, no Bundle.getString).
// Track active conversation from nav back stack
LaunchedEffect(navBackStackEntry) {
val destRoute = navBackStackEntry?.destination?.route
val conversationId = if (destRoute == CHAT_ROUTE) {
navBackStackEntry?.arguments?.read { getStringOrNull("conversationId") }
val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
navBackStackEntry?.toRoute<Chat>()?.conversationId
} else {
null
}
@ -108,13 +121,13 @@ fun LibreChatNavHost(
// Handle session expiry
LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect {
navController.navigate(AUTH_GRAPH_ROUTE) {
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
}
}
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
PhoneLayout(
navController = navController,
@ -174,7 +187,7 @@ private fun VersionMismatchDialog(
private fun PhoneLayout(
navController: androidx.navigation.NavHostController,
navHostViewModel: NavHostViewModel,
startDestination: String,
startDestination: Any,
isInAuthFlow: Boolean,
modifier: Modifier = Modifier,
) {
@ -199,10 +212,10 @@ private fun PhoneLayout(
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 }
val currentRoute = navController.currentBackStackEntry?.destination
if (!currentRoute.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
}
@ -213,7 +226,7 @@ private fun PhoneLayout(
},
onSettingsClick = {
scope.launch { drawerState.close() }
navController.navigate(SETTINGS_TABBED_ROUTE) {
navController.navigate(SettingsTabbed) {
launchSingleTop = true
}
},
@ -224,13 +237,13 @@ private fun PhoneLayout(
},
onAgentsClick = {
scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.AGENTS.route) {
navController.navigate(AgentMarketplace) {
launchSingleTop = true
}
},
onFilesClick = {
scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.FILES.route) {
navController.navigate(Files) {
launchSingleTop = true
}
},
@ -283,8 +296,8 @@ private fun PhoneLayout(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
navController.navigate(NewChat) {
popUpTo<AuthRoute> { inclusive = true }
}
},
)
@ -299,7 +312,7 @@ private fun PhoneLayout(
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate("conversations/archived")
navController.navigate(ArchivedConversations)
},
onNavigateBackFromArchived = {
navController.popBackStack()
@ -307,20 +320,20 @@ private fun PhoneLayout(
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate("agents/$agentId")
navController.navigate(AgentDetail(agentId = agentId))
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
navController.navigate(AgentEditorCreate)
},
onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId")
navController.navigate(AgentEditorEdit(agentId = agentId))
},
)
filesGraph(
@ -329,18 +342,18 @@ private fun PhoneLayout(
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) {
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate("conversations/archived") {
navController.navigate(ArchivedConversations) {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) {
navController.navigate(SharedLinks) {
launchSingleTop = true
}
},
@ -348,7 +361,7 @@ private fun PhoneLayout(
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) {
navController.navigate(PresetManager) {
launchSingleTop = true
}
},
@ -356,7 +369,7 @@ private fun PhoneLayout(
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) {
navController.navigate(ApiKeys) {
launchSingleTop = true
}
},

View file

@ -0,0 +1,23 @@
package com.garfiec.librechat.shared.navigation
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.
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
*/
val navigationSerializersModule: SerializersModule =
authSerializersModule +
chatSerializersModule +
conversationsSerializersModule +
agentsSerializersModule +
filesSerializersModule +
settingsSerializersModule