From 2756efa812ebf7e30465b385f041471438fbb75c Mon Sep 17 00:00:00 2001 From: Garfie Date: Fri, 3 Apr 2026 20:10:52 -0500 Subject: [PATCH] refactor: convert navigation to type-safe routes with polymorphic serialization --- app/build.gradle.kts | 1 + .../garfiec/librechat/navigation/AppModule.kt | 3 + .../librechat/navigation/LibreChatNavHost.kt | 118 ++++++++++-------- .../navigation/NavigationSerializers.kt | 23 ++++ .../librechat/navigation/TabletLayout.kt | 85 +++++++------ .../agents/navigation/AgentsNavigation.kt | 43 ++++--- .../feature/auth/navigation/AuthNavigation.kt | 104 +++++++-------- .../feature/chat/navigation/ChatNavigation.kt | 74 ++++++----- .../navigation/ConversationsNavigation.kt | 21 +++- .../files/navigation/FilesNavigation.kt | 16 ++- .../settings/navigation/McpNavigation.kt | 5 +- .../settings/navigation/MemoriesNavigation.kt | 5 +- .../settings/navigation/SettingsNavigation.kt | 53 +++++--- .../librechat/shared/navigation/AppModule.kt | 3 + .../shared/navigation/LibreChatNavHost.kt | 113 +++++++++-------- .../navigation/NavigationSerializers.kt | 23 ++++ 16 files changed, 412 insertions(+), 278 deletions(-) create mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt create mode 100644 shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 744496a..361f549 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("librechat.mobile.application") id("librechat.mobile.compose") id("librechat.mobile.koin") + id("librechat.kotlin.serialization") } android { diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt index 5af22ad..1569555 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt @@ -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(named("navigation")) { navigationSerializersModule } } diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt index f17adc8..00ee3ed 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt @@ -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()?.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 { 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 { 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 { 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 { 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 } }, diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt new file mode 100644 index 0000000..be92769 --- /dev/null +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt @@ -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 diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt index aafb685..46bb647 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt @@ -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 { 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 { 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, dismissedBannerIds: Set, @@ -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 { 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 { 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 } }, diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt index 90ce060..52b92bf 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt @@ -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 { AgentMarketplaceScreen( onAgentClick = onAgentClick, onCreateAgent = onCreateAgent, onBack = onBack, ) } - composable( - route = AGENT_DETAIL_ROUTE, - arguments = listOf( - navArgument("agentId") { type = NavType.StringType }, - ), - ) { + composable { AgentDetailScreen( onBack = onBack, onStartChat = onStartChat, @@ -40,21 +39,25 @@ fun NavGraphBuilder.agentsGraph( onDuplicated = onAgentClick, ) } - composable(AGENT_EDITOR_CREATE_ROUTE) { + composable { AgentEditorScreen( onBack = onBack, onSaved = { agentId -> onAgentClick(agentId) }, ) } - composable( - route = AGENT_EDITOR_EDIT_ROUTE, - arguments = listOf( - navArgument("agentId") { type = NavType.StringType }, - ), - ) { + composable { 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()) + } +} diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt index 1f95540..196ffb4 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt @@ -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(startDestination = ServerUrl::class) { + composable { ScreenTransitionWrapper(transition) { ServerUrlScreen( - onServerValidated = { navController.navigate(LOGIN_ROUTE) }, + onServerValidated = { navController.navigate(Login) }, ) } } - composable(LOGIN_ROUTE) { + composable { 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 { ScreenTransitionWrapper(transition) { RegisterScreen( - onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, + onRegistered = { navController.popBackStack(inclusive = false) }, onNavigateToLogin = { navController.popBackStack() }, ) } } - composable(FORGOT_PASSWORD_ROUTE) { + composable { ScreenTransitionWrapper(transition) { ForgotPasswordScreen( onBack = { navController.popBackStack() }, ) } } - composable( - route = TWO_FACTOR_ROUTE, - arguments = listOf(navArgument("tempToken") { type = NavType.StringType }), - ) { + composable { 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 { ScreenTransitionWrapper(transition) { VerifyEmailScreen( - onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, + onVerified = { navController.popBackStack(inclusive = false) }, onBack = { navController.popBackStack() }, ) } } - composable(TERMS_ROUTE) { + composable { 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 { ScreenTransitionWrapper(transition) { ResetPasswordScreen( - onResetComplete = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, + onResetComplete = { navController.popBackStack(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()) + } +} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt index 70e3d4d..33050c6 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt @@ -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(startDestination = NewChat::class) { + composable( 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( 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 { 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 { 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()) + } +} diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt index 769b56d..d09dbd3 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt @@ -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 { ScreenTransitionWrapper(transition) { ConversationListScreen( onConversationClick = onConversationClick, @@ -22,7 +28,7 @@ fun NavGraphBuilder.conversationsGraph( ) } } - composable(ARCHIVED_ROUTE) { + composable { 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()) + } +} diff --git a/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt b/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt index 2bdf8db..82fb823 100644 --- a/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt +++ b/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt @@ -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 { ScreenTransitionWrapper(transition) { FilesScreen(onBack = onBack) } } } + +val filesSerializersModule = SerializersModule { + polymorphic(FilesRoute::class) { + subclass(Files::class, Files.serializer()) + } +} diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt index 14192d5..68c384c 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt @@ -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 { McpServersScreen( onNavigateBack = onNavigateBack, ) diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt index e3e3b15..d96d8df 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt @@ -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 { MemoriesScreen( onNavigateBack = onNavigateBack, ) diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt index a987b7d..f519f63 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt @@ -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 { TabbedSettingsScreen( onNavigateBack = onNavigateBack, onLogout = onLogout, @@ -46,32 +52,32 @@ fun NavGraphBuilder.settingsGraph( ) } - composable(SETTINGS_GENERAL_ROUTE) { + composable { GeneralSettingsScreen( onNavigateBack = onNavigateBack, ) } - composable(SETTINGS_CHAT_ROUTE) { + composable { ChatSettingsScreen( onNavigateBack = onNavigateBack, onNavigateToPresets = onNavigateToPresets, ) } - composable(SETTINGS_ACCOUNT_ROUTE) { + composable { AccountSettingsScreen( onLogout = onLogout, onNavigateBack = onNavigateBack, onNavigateToApiKeys = onNavigateToApiKeys, ) } - composable(SETTINGS_DATA_ROUTE) { + composable { DataSettingsScreen( onNavigateBack = onNavigateBack, onNavigateToArchived = onNavigateToArchived, onNavigateToSharedLinks = onNavigateToSharedLinks, ) } - composable(SHARED_LINKS_ROUTE) { + composable { val viewModel: SettingsViewModel = koinViewModel() val uiState = viewModel.uiState.collectAsStateWithLifecycle() @@ -90,14 +96,29 @@ fun NavGraphBuilder.settingsGraph( onNavigateBack = onNavigateBackFromSharedLinks, ) } - composable(PRESET_MANAGER_ROUTE) { + composable { PresetManagerScreen( onNavigateBack = onNavigateBackFromPresets, ) } - composable(API_KEYS_ROUTE) { + composable { 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()) + } +} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt index 5743725..2bf2268 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt @@ -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(named("navigation")) { navigationSerializersModule } } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt index 85a71d0..6ae039b 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt @@ -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()?.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 { 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 { 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 { 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 } }, diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt new file mode 100644 index 0000000..4839769 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt @@ -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