From 2756efa812ebf7e30465b385f041471438fbb75c Mon Sep 17 00:00:00 2001 From: Garfie Date: Fri, 3 Apr 2026 20:10:52 -0500 Subject: [PATCH 1/3] 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 From 40ee348b125e955ae5ee505e3d58c7cd57da6c46 Mon Sep 17 00:00:00 2001 From: Garfie Date: Fri, 3 Apr 2026 20:44:38 -0500 Subject: [PATCH 2/3] refactor: migrate from Navigation Compose 2 to Navigation Compose 3 --- CLAUDE.md | 9 +- KNOWN_ISSUES.md | 9 - NAV3_RESEARCH.md | 841 ++++++++++++++++++ app/CLAUDE.md | 16 +- app/build.gradle.kts | 3 +- .../com/garfiec/librechat/MainActivity.kt | 4 +- .../librechat/navigation/DrawerContent.kt | 2 +- .../librechat/navigation/LibreChatNavHost.kt | 348 +++----- .../navigation/NavigationSerializers.kt | 11 +- .../garfiec/librechat/navigation/Navigator.kt | 60 ++ .../librechat/navigation/TabletLayout.kt | 223 +---- .../navigation/TopLevelDestination.kt | 28 - .../librechat/navigation/NavigatorTest.kt | 138 +++ build-logic/CLAUDE.md | 25 +- .../kotlin/AndroidFeatureConventionPlugin.kt | 3 +- .../main/kotlin/KmpFeatureConventionPlugin.kt | 3 +- core/ui/CLAUDE.md | 2 +- .../ui/components/ScreenTransitionWrapper.kt | 72 -- .../ui/components/ScreenTransitionWrapper.kt | 18 - .../components/ScreenTransitionWrapper.ios.kt | 38 - feature/agents/CLAUDE.md | 5 +- .../feature/agents/di/AgentsModule.kt | 12 +- .../agents/navigation/AgentsNavigation.kt | 37 +- .../agents/screen/AgentDetailScreen.kt | 4 +- .../agents/screen/AgentEditorScreen.kt | 4 +- .../agents/viewmodel/AgentDetailViewModel.kt | 3 +- .../agents/viewmodel/AgentEditorViewModel.kt | 3 +- feature/auth/CLAUDE.md | 10 +- .../librechat/feature/auth/di/AuthModule.kt | 26 +- .../feature/auth/navigation/AuthNavigation.kt | 144 ++- .../auth/screen/ResetPasswordScreen.kt | 5 +- .../feature/auth/screen/TwoFactorScreen.kt | 4 +- .../feature/auth/screen/VerifyEmailScreen.kt | 4 +- .../auth/viewmodel/ResetPasswordViewModel.kt | 6 +- .../auth/viewmodel/TwoFactorViewModel.kt | 3 +- .../auth/viewmodel/VerifyEmailViewModel.kt | 3 +- feature/chat/CLAUDE.md | 9 +- .../chat/di/ChatPlatformModule.android.kt | 3 +- .../feature/chat/screen/ChatScreen.kt | 10 +- .../librechat/feature/chat/di/ChatModule.kt | 9 +- .../feature/chat/navigation/ChatNavigation.kt | 126 +-- .../chat/prompts/PromptEditorScreen.kt | 4 +- .../chat/prompts/PromptEditorViewModel.kt | 3 +- .../feature/chat/screen/PlatformScreens.kt | 1 + .../feature/chat/viewmodel/ChatUiState.kt | 2 +- .../feature/chat/viewmodel/ChatViewModel.kt | 7 +- .../feature/chat/di/ChatPlatformModule.ios.kt | 3 +- .../chat/screen/PlatformScreens.ios.kt | 4 +- feature/conversations/CLAUDE.md | 2 +- .../navigation/ConversationsNavigation.kt | 35 +- feature/files/CLAUDE.md | 2 +- .../files/navigation/FilesNavigation.kt | 17 +- feature/settings/CLAUDE.md | 6 +- .../settings/navigation/McpNavigation.kt | 12 +- .../settings/navigation/MemoriesNavigation.kt | 12 +- .../settings/navigation/SettingsNavigation.kt | 64 +- gradle/libs.versions.toml | 9 +- shared/build.gradle.kts | 3 +- .../shared/navigation/LibreChatNavHost.kt | 310 +++---- .../navigation/NavigationSerializers.kt | 11 +- .../librechat/shared/navigation/Navigator.kt | 60 ++ .../shared/navigation/TopLevelDestination.kt | 24 - 62 files changed, 1747 insertions(+), 1127 deletions(-) create mode 100644 NAV3_RESEARCH.md create mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/TopLevelDestination.kt create mode 100644 app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt delete mode 100644 core/ui/src/androidMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt delete mode 100644 core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt delete mode 100644 core/ui/src/iosMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.ios.kt create mode 100644 shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/Navigator.kt delete mode 100644 shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/TopLevelDestination.kt diff --git a/CLAUDE.md b/CLAUDE.md index 34aa252..ead9692 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,18 +4,19 @@ Native mobile client for LibreChat (Android & iOS). Connects to existing LibreCh ## Tech Stack -- **UI**: Jetpack Compose + Compose Navigation +- **UI**: Jetpack Compose + Navigation Compose 3 (Nav 3) - **DI**: Koin (KMP-ready) - **Network**: Ktor Client (OkHttp engine) - **Serialization**: Kotlinx Serialization - **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens) -- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26 +- **Build**: Gradle 9.4.1, AGP 9.1.0, Kotlin 2.3.20, compileSdk 36, minSdk 26 ## Module Layout ``` app/ → Single Activity, adaptive navigation (phone/tablet) -build-logic/ → 7 convention plugins for consistent Gradle config +shared/ → KMP shared framework (iOS entry point, shared navigation) +build-logic/ → 13 convention plugins for consistent Gradle config core/common/ → Result type, dispatcher DI, coroutine scopes, extensions core/model/ → @Serializable data classes (pure Kotlin, no Android deps) core/network/ → Ktor client, 16 API services, SSE client, auth interceptor @@ -34,7 +35,7 @@ Each module has its own `CLAUDE.md` with specific guidance. ## Architecture Rules - Feature modules depend on `:core:*` only, never on each other -- Single Activity with Compose Navigation +- Single Activity with Nav 3 (`NavDisplay` + `NavBackStack` + `entryProvider`) - Unidirectional data flow: UI → ViewModel → Repository → API/Room - Room is a read-through cache; server is source of truth - Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 0c8762b..17bbd57 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1,14 +1,5 @@ # Known Issues -## Tablet back navigation doesn't dismiss sidebar first - -**Status**: Known issue, to be fixed later - -**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen. - -**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost. - -**Workaround**: Tap the hamburger menu button to toggle the sidebar closed. ## Chat search: sub-message scroll precision diff --git a/NAV3_RESEARCH.md b/NAV3_RESEARCH.md new file mode 100644 index 0000000..4a3a7d8 --- /dev/null +++ b/NAV3_RESEARCH.md @@ -0,0 +1,841 @@ +# Navigation Compose 3: Research Report + +> Compiled 2026-04-03. Sources: Android Developer docs, JetBrains Kotlin docs, nav3-recipes repo, Android Developers Blog, community blog posts, and release notes. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Core Concepts & API](#2-core-concepts--api) +3. [Best Practices](#3-best-practices) +4. [Migration Guide (Nav 2 → Nav 3)](#4-migration-guide-nav-2--nav-3) +5. [Gotchas, Footguns & Complications](#5-gotchas-footguns--complications) +6. [KMP / Compose Multiplatform Considerations](#6-kmp--compose-multiplatform-considerations) +7. [Appendix: Version & Dependency Reference](#7-appendix-version--dependency-reference) + +--- + +## 1. Overview + +### What is Navigation 3? + +Navigation 3 (Nav3) is a **ground-up rewrite** of Android's Jetpack Navigation, designed specifically for Jetpack Compose. Released as stable **1.0.0 on November 19, 2025**, it replaces the controller-based paradigm of Nav 2 with a **state-based, declarative approach** where the developer owns the back stack directly. + +- **Stable release**: 1.0.1 (Feb 11, 2026) +- **Latest**: 1.1.0-rc01 (Mar 25, 2026) +- **Min SDK**: 23 +- **Compile SDK**: 36+ + +### How It Differs from Nav 2 + +| Aspect | Nav 2 | Nav 3 | +|--------|-------|-------| +| **State ownership** | Library-owned (`NavController`) | Developer-owned (`SnapshotStateList`) | +| **Back stack model** | Hidden internal structure | Transparent mutable list | +| **Navigation API** | `navController.navigate()` | `backStack.add(key)` | +| **Host component** | `NavHost` | `NavDisplay` | +| **Route definition** | `composable { }` in `NavHost` | `entry { }` in `entryProvider` | +| **Layout flexibility** | Single pane only | Multi-pane via Scenes API | +| **Nested graphs** | `navigation { }` | Not needed — flat entry list | +| **Design philosophy** | Opinionated framework | Flexible building blocks | +| **Compose alignment** | Bolted-on Compose support | Compose-first from scratch | + +### Founding Principles + +1. **You Own the Back Stack** — Navigation state is a `SnapshotStateList` you control directly. Push = `add()`, pop = `removeLastOrNull()`. +2. **Get Out of Your Way** — The library provides open, extensible building blocks rather than a black box. +3. **Pick Your Building Blocks** — Composable components you combine for your specific needs, with recipes for complex scenarios. + +### Data Flow + +``` +User Action → modify backStack (add/remove) → NavDisplay observes change → entryProvider resolves key → NavEntry.content rendered +``` + +This follows **Unidirectional Data Flow (UDF)**, fully aligned with Compose's declarative model. + +--- + +## 2. Core Concepts & API + +### 2.1 Keys (Routes) + +Routes in Nav 3 are simple Kotlin data types that implement `NavKey`: + +```kotlin +@Serializable +data object Home : NavKey + +@Serializable +data class ProductDetail(val id: String) : NavKey + +@Serializable +data object Settings : NavKey +``` + +Keys must be: +- `@Serializable` (for state persistence) +- Implement `NavKey` (marker interface for `rememberNavBackStack`) + +### 2.2 Back Stack + +The back stack is a `SnapshotStateList` — you own it completely: + +```kotlin +// Simple (no persistence across process death) +val backStack = remember { mutableStateListOf(Home) } + +// Persistent (survives config changes + process death) +val backStack = rememberNavBackStack(Home) +``` + +Navigation is just list manipulation: +```kotlin +// Navigate forward +backStack.add(ProductDetail(id = "123")) + +// Navigate back +backStack.removeLastOrNull() +``` + +### 2.3 NavEntry + +`NavEntry` wraps a composable function with its key and optional metadata: + +```kotlin +NavEntry( + key = ProductDetail("123"), + metadata = mapOf("type" to "detail") +) { + ProductDetailScreen() +} +``` + +### 2.4 Entry Provider + +Resolves keys to `NavEntry` objects. Two approaches: + +**Lambda function:** +```kotlin +entryProvider = { key -> + when (key) { + is Home -> NavEntry(key) { HomeScreen() } + is ProductDetail -> NavEntry(key) { ProductDetailScreen(key.id) } + else -> NavEntry(Unit) { Text("Unknown") } + } +} +``` + +**DSL (recommended):** +```kotlin +entryProvider = entryProvider { + entry { HomeScreen() } + entry { key -> ProductDetailScreen(key.id) } +} +``` + +### 2.5 NavDisplay + +The primary UI component that observes the back stack and renders content: + +```kotlin +NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + sceneStrategies = listOf(DialogSceneStrategy()), + entryProvider = entryProvider { + entry { HomeScreen() } + entry { key -> ProductDetailScreen(key.id) } + } +) +``` + +### 2.6 NavEntry Decorators + +Decorators wrap each `NavEntry` with additional logic (state holders, ViewModel stores, logging): + +| Decorator | Purpose | +|-----------|---------| +| `rememberSaveableStateHolderNavEntryDecorator()` | Persists `rememberSaveable` state across config changes and process death. **Should be first in the list.** | +| `rememberViewModelStoreNavEntryDecorator()` | Scopes `ViewModel`s to individual `NavEntry` lifecycle. Requires `androidx.lifecycle:lifecycle-viewmodel-navigation3` dependency. | +| `rememberSharedViewModelStoreNavEntryDecorator()` | Enables ViewModel sharing between parent/child entries. | + +Custom decorator example: +```kotlin +class LoggingDecorator : NavEntryDecorator( + decorate = { entry -> + Log.d("Nav", "Entered ${entry.contentKey}") + entry.Content() + }, + onPop = { key -> Log.d("Nav", "Popped $key") } +) +``` + +### 2.7 Scenes & SceneStrategy + +**Scenes** render one or more `NavEntry` instances. **SceneStrategies** determine which Scene to use based on the current entries. + +**Built-in strategies:** +- `SinglePaneSceneStrategy` — Default, shows topmost entry +- `DialogSceneStrategy` — Renders entries with dialog metadata as overlays +- `ListDetailSceneStrategy` (from `material3-adaptive-navigation3`) — Adaptive list-detail layout + +**Custom strategy example (list-detail):** +```kotlin +class ListDetailSceneStrategy( + val windowSizeClass: WindowSizeClass +) : SceneStrategy { + override fun SceneStrategyScope.calculateScene( + entries: List> + ): Scene? { + if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) + return null + val detailEntry = entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null + val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null + return ListDetailScene( + key = listEntry.contentKey, + previousEntries = entries.dropLast(1), + listEntry = listEntry, + detailEntry = detailEntry + ) + } +} +``` + +### 2.8 Metadata + +Arbitrary `Map` attached to `NavEntry` or `Scene` for communication between components: + +```kotlin +// Type-safe metadata DSL (1.1.0-beta01+) +entry( + metadata = metadata { + put(NavDisplay.TransitionKey) { fadeIn() togetherWith fadeOut() } + put(ListDetailSceneStrategy.ListKey, true) + } +) { HomeScreen() } +``` + +### 2.9 Animations + +NavDisplay supports built-in transition animations: + +```kotlin +NavDisplay( + backStack = backStack, + // Global defaults + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + }, + popTransitionSpec = { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + predictivePopTransitionSpec = { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + entryProvider = entryProvider { + // Per-entry override via metadata + entry( + metadata = metadata { + put(NavDisplay.TransitionKey) { + slideInVertically(initialOffsetY = { it }) togetherWith + ExitTransition.KeepUntilTransitionsFinished + } + } + ) { ScreenC() } + } +) +``` + +Shared element transitions: +```kotlin +SharedTransitionLayout { + NavDisplay( + sharedTransitionScope = this, + // ... + ) +} +``` + +### 2.10 Deep Linking + +Nav 3 does **not** have built-in deep link support like Nav 2. Instead, you parse intents manually: + +1. Define `DeepLinkPattern` objects mapping URIs to `NavKey` classes +2. Parse incoming `Intent.data` into a `DeepLinkRequest` +3. Match against patterns to produce a `NavKey` +4. Build a synthetic back stack for proper Up navigation + +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + val uri: Uri? = intent.data + val key: NavKey = uri?.let { parseDeepLink(it) } ?: HomeKey + setContent { + val backStack = rememberNavBackStack(key) + NavDisplay(backStack = backStack, ...) + } +} +``` + +Recipes available: [Basic deep links](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplinks), [Advanced deep links](https://github.com/android/nav3-recipes/tree/main/advanceddeeplinkapp). + +--- + +## 3. Best Practices + +### 3.1 Architecture Pattern + +**Recommended: Navigator + NavigationState pattern** (from official migration guide): + +```kotlin +// NavigationState — holds all navigation state +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> +) { + var topLevelRoute: NavKey by topLevelRoute +} + +// Navigator — handles navigation events +class Navigator(val state: NavigationState) { + fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + fun goBack() { /* ... */ } +} +``` + +### 3.2 Multi-Module Navigation + +Nav 3 is designed for modular navigation. Each feature module exposes: +- **`api` module**: Navigation keys (routes) +- **`impl` module**: Entry builders (content) + +```kotlin +// feature/chat/impl +fun EntryProviderScope.chatEntryBuilder() { + entry { key -> ChatScreen(key.conversationId) } + entry { ChatSettingsScreen() } +} + +// app module +NavDisplay( + entryProvider = entryProvider { + chatEntryBuilder() + authEntryBuilder() + settingsEntryBuilder() + } +) +``` + +**With Koin DI** (from nav3-recipes): +```kotlin +// Each feature module provides its entry builder +val chatNavigationModule = module { + factory.() -> Unit>(named("chat")) { + { chatEntryBuilder(get()) } + } +} + +// App module collects all builders +val entryBuilders: List.() -> Unit> = getAll() +NavDisplay( + entryProvider = entryProvider { + entryBuilders.forEach { builder -> this.builder() } + } +) +``` + +### 3.3 ViewModel Scoping + +ViewModels are scoped to `NavEntry` lifecycle via decorators: + +```kotlin +NavDisplay( + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), // Always first + rememberViewModelStoreNavEntryDecorator() + ), + // ... +) +``` + +With this setup: +- `viewModel()` calls inside NavEntry content are scoped to that entry +- ViewModels survive config changes +- ViewModels are cleared when the entry is popped + +**With Koin:** +```kotlin +entry { key -> + val viewModel = koinViewModel() + ChatScreen(viewModel) +} +``` + +### 3.4 Multiple Back Stacks (Bottom Navigation) + +For apps with bottom navigation / top-level routes: + +```kotlin +val topLevelRoutes = setOf(Home, Search, Profile) + +// Each top-level route gets its own persistent back stack +val backStacks = topLevelRoutes.associateWith { rememberNavBackStack(it) } + +// Switch between stacks (state is retained) +var currentTopLevel by remember { mutableStateOf(Home) } +``` + +Recipe: [Multiple back stacks](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/multiplestacks) + +### 3.5 Adaptive / Multi-Pane Layouts + +Use Material 3 Adaptive for responsive list-detail: + +```kotlin +val listDetailStrategy = rememberListDetailSceneStrategy() + +NavDisplay( + backStack = backStack, + sceneStrategies = listOf(listDetailStrategy), + entryProvider = entryProvider { + entry( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { Text("Select a conversation") } + ) + ) { ConversationListScreen() } + + entry( + metadata = ListDetailSceneStrategy.detailPane() + ) { key -> ConversationDetailScreen(key.id) } + } +) +``` + +### 3.6 Testing Navigation + +**Nav 3 has no dedicated testing library like Nav 2's `navigation-testing`.** However, since navigation state is just a list, testing is simpler: + +```kotlin +@Test +fun testNavigation() { + val backStack = mutableStateListOf(Home) + + // Simulate navigation + backStack.add(ProductDetail("123")) + assertEquals(ProductDetail("123"), backStack.last()) + + // Simulate back + backStack.removeLastOrNull() + assertEquals(Home, backStack.last()) +} +``` + +For UI testing, you can use standard Compose testing: +```kotlin +@Test +fun testNavigationUI() { + composeTestRule.setContent { + val backStack = remember { mutableStateListOf(Home) } + NavDisplay( + backStack = backStack, + entryProvider = entryProvider { /* ... */ } + ) + } + // Assert on rendered content +} +``` + +The `SceneStrategyScope` has a no-arg public constructor specifically for testing (added in 1.0.0-beta01). + +--- + +## 4. Migration Guide (Nav 2 → Nav 3) + +### 4.1 Prerequisites + +- `compileSdk` 36+ +- `minSdk` 23+ +- All destinations must be composable functions +- Routes must implement `NavKey` + +### 4.2 Can Nav 2 and Nav 3 Coexist? + +**Yes.** The official migration guide mentions **atomic migration** as the recommended approach, but community sources confirm **incremental migration** is possible: + +- Nav 2 and Nav 3 can operate simultaneously during migration +- Features can be migrated one at a time +- Business logic and ViewModels don't need rewriting + +However, the official migration guide assumes atomic migration. For incremental, you'd maintain a Nav 2 `NavHost` for unmigrated features and a Nav 3 `NavDisplay` for migrated ones, coordinating between them. + +### 4.3 Step-by-Step Migration + +#### Step 1: Add Dependencies + +```toml +[versions] +nav3Core = "1.0.1" +lifecycleViewmodelNav3 = "2.11.0-alpha03" + +[libraries] +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } +``` + +#### Step 2: Update Routes to Implement NavKey + +```kotlin +// Before +@Serializable data object Home +@Serializable data class Chat(val conversationId: String) + +// After +@Serializable data object Home : NavKey +@Serializable data class Chat(val conversationId: String) : NavKey +``` + +#### Step 3: Create NavigationState & Navigator + +See [Section 3.1](#31-architecture-pattern) for the full pattern. + +#### Step 4: Replace NavController Calls + +| Nav 2 | Nav 3 | +|-------|-------| +| `navController.navigate(route)` | `navigator.navigate(route)` or `backStack.add(route)` | +| `navController.popBackStack()` | `navigator.goBack()` or `backStack.removeLastOrNull()` | +| `navController.currentDestination` | `backStack.last()` | +| `currentBackStackEntryAsState()` | Direct state observation of `backStack` | +| `destination.isRouteInHierarchy()` | `key == navigationState.topLevelRoute` | + +#### Step 5: Convert Destinations + +| Nav 2 | Nav 3 | +|-------|-------| +| `composable { }` | `entry { }` | +| `dialog { }` | `entry(metadata = DialogSceneStrategy.dialog()) { }` | +| `navigation(startDestination = ...) { }` | **Delete** — replace references with first child route | +| `NavGraphBuilder` extension | `EntryProviderScope` extension | +| `entry.toRoute()` | `key` parameter: `entry { key -> ... }` | + +**Before:** +```kotlin +NavHost(navController = navController, startDestination = Home) { + composable { HomeScreen() } + composable { entry -> + val route = entry.toRoute() + ChatScreen(route.conversationId) + } + dialog { ConfirmDialogScreen() } +} +``` + +**After:** +```kotlin +NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(DialogSceneStrategy()), + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { HomeScreen() } + entry { key -> ChatScreen(key.conversationId) } + entry( + metadata = DialogSceneStrategy.dialog() + ) { ConfirmDialogScreen() } + } +) +``` + +#### Step 6: Remove Nav 2 Dependencies + +Remove all Nav 2 imports, `NavController`, `NavHost`, `NavGraphBuilder`, and gradle dependencies. + +### 4.4 Key Differences to Watch + +1. **No NavController** — State is a mutable list, not a controller object +2. **No nested graphs** — Delete `navigation` blocks entirely +3. **No built-in deep links** — Must implement manually (see recipes) +4. **Route args via key param** — `entry { key -> ... }` instead of `toRoute()` +5. **Dialogs via metadata** — `DialogSceneStrategy.dialog()` metadata instead of `dialog` +6. **Use `rememberSerializable`, not `rememberSaveable`** — For top-level route persistence +7. **Decorators are explicit** — Must add state/ViewModel decorators manually + +--- + +## 5. Gotchas, Footguns & Complications + +### 5.1 Known Issues (from release notes) + +| Issue | Status | Versions | +|-------|--------|----------| +| z-order bug during animation interruptions ([b/459419800](https://issuetracker.google.com/issues/459419800)) | Fixed in 1.1.0-rc01 | | +| Lifecycle not capped at STARTED for entries under overlays ([b/483966071](https://issuetracker.google.com/issues/483966071)) | Fixed in 1.1.0-rc01 | | +| Crash with SharedTransitionLayout + OverlayScene ([b/478664101](https://issuetracker.google.com/issues/478664101)) | Fixed in 1.1.0-alpha04 | | +| IllegalStateException in Android Studio Previews ([b/477149762](https://issuetracker.google.com/issues/477149762)) | Fixed in 1.0.1 / NavigationEvent 1.0.2 | | +| Infinite scene recalculation ([b/418153031](https://issuetracker.google.com/issues/418153031)) | Fixed in 1.0.0-alpha07 | | +| Screen flicker when swapping backStack with animations ([b/450967248](https://issuetracker.google.com/issues/450967248)) | Fixed in 1.0.0-beta01 | | + +### 5.2 Footguns + +1. **Decorator order matters** — `rememberSaveableStateHolderNavEntryDecorator()` must be **first** in the decorators list. Otherwise, `rememberSaveable` won't work inside NavEntry content. + +2. **Animations in adaptive layouts** — When using `ListDetailSceneStrategy` in two-pane mode (>=600dp), transition animations between top-level destinations may not work. The screen "jumps" without animation. + +3. **No built-in deep link handling** — Unlike Nav 2's `deepLinks { }` DSL, you must parse intents and build synthetic back stacks yourself. See the recipes repo for patterns. + +4. **`rememberNavBackStack` requires NavKey** — If you want persistence across process death, all keys must implement `NavKey` and be `@Serializable`. Simple `data object` without `NavKey` will work but won't survive process death. + +5. **compileSdk 36 required** — This is a hard requirement. If your project is on an older compileSdk, you must upgrade. + +6. **No nested navigation (>1 level)** — The migration guide explicitly states: only one level of nesting is supported. Shared destinations (screens moving between different back stacks) are also unsupported. + +7. **`calculateScene` is no longer `@Composable` (since 1.0.0-alpha11)** — Custom SceneStrategies cannot call composable functions in `calculateScene`. Use `remember` in a factory function instead. + +8. **NavBackStack serialization is Android-only** — The built-in `NavBackStackSerializer` is restricted to Android ([b/420443609](https://issuetracker.google.com/issues/420443609)). For KMP, you need polymorphic serialization (see Section 6). + +9. **API churn in 1.1.x** — The 1.1 branch is still in RC. APIs like `SceneStrategy` receiving `List` instead of chained strategies changed in alpha05. Pin to 1.0.1 for stability. + +### 5.3 Things Harder in Nav 3 + +- **Deep linking**: Significantly more manual work required +- **Bottom sheet destinations**: No built-in support; recipe available but requires custom SceneStrategy +- **Fragment/View interop**: Must use Views-in-Compose wrappers; Nav 3 is Compose-only +- **Shared destinations between back stacks**: Not supported +- **Testing**: No dedicated testing artifact; testing is easier conceptually (just a list) but lacks the convenience APIs of `TestNavHostController` + +### 5.4 Things Easier in Nav 3 + +- **Back stack inspection**: It's just a list — fully observable and debuggable +- **Multi-pane/adaptive layouts**: First-class support via Scenes +- **Custom navigation logic**: No fighting the NavController +- **State management**: Aligns perfectly with Compose state +- **ViewModel scoping**: Explicit, clear, and configurable via decorators + +--- + +## 6. KMP / Compose Multiplatform Considerations + +### 6.1 Is Nav 3 Available for Compose Multiplatform? + +**Yes.** JetBrains added Nav 3 support in **Compose Multiplatform 1.10.0** (January 2026) for all platforms: Android, iOS, desktop, and web. + +### 6.2 Dependencies (KMP) + +```toml +[versions] +multiplatform-nav3-ui = "1.0.0-alpha05" + +[libraries] +jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } +``` + +Nav 3 ships as two artifacts: +- `navigation3-common` — Core (transitive dependency) +- `navigation3-ui` — NavDisplay and UI components (separate CMP implementation) + +Additional KMP libraries: +```toml +# Material 3 Adaptive +jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } + +# ViewModel support +jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } +``` + +### 6.3 Critical KMP Requirement: Polymorphic Serialization + +**On Android**, Nav 3 uses reflection-based serialization for `NavKey` types. **This does NOT work on non-JVM platforms (iOS, web).** You must use polymorphic serialization. + +`rememberNavBackStack()` has two overloads: +1. **Android-only**: `rememberNavBackStack(startKey)` — uses reflection +2. **Multiplatform**: `rememberNavBackStack(config, startKey)` — requires `SavedStateConfiguration` + +**For KMP, you MUST use the second overload:** + +```kotlin +@Serializable +data object Home : NavKey + +@Serializable +data class Chat(val id: String) : NavKey + +// Required for non-JVM platforms +private val config = SavedStateConfiguration { + serializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(Home::class, Home.serializer()) + subclass(Chat::class, Chat.serializer()) + } + } +} + +@Composable +fun App() { + val backStack = rememberNavBackStack(config, Home) + NavDisplay(backStack = backStack, ...) +} +``` + +### 6.4 Multi-Module KMP Pattern + +For our multi-module setup, use sealed interfaces per feature module: + +```kotlin +// feature/auth/api +@Serializable +sealed interface AuthRoute : NavKey + +@Serializable data object Login : AuthRoute +@Serializable data object Register : AuthRoute + +// feature/chat/api +@Serializable +sealed interface ChatRoute : NavKey + +@Serializable data class Conversation(val id: String) : ChatRoute + +// app module — aggregate all feature routes +private val config = SavedStateConfiguration { + serializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclassesOfSealed() + subclassesOfSealed() + } + } +} +``` + +Alternatively, with Koin DI, each module can expose a `SerializersModule` and the app module combines them: + +```kotlin +// Each feature module +val chatSerializerModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(Conversation::class, Conversation.serializer()) + } +} + +// App module +val combinedConfig = SavedStateConfiguration { + serializersModule = chatSerializerModule + authSerializerModule + settingsSerializerModule +} +``` + +### 6.5 Impact on Our Current Setup + +We currently use `org.jetbrains.androidx.navigation:navigation-compose:2.9.2`. For Nav 3: + +| Current (Nav 2 KMP) | Nav 3 KMP | +|---------------------|-----------| +| `org.jetbrains.androidx.navigation:navigation-compose:2.9.2` | `org.jetbrains.androidx.navigation3:navigation3-ui:1.0.0-alpha05` | +| `NavHost` | `NavDisplay` | +| `NavController` | `SnapshotStateList` + custom Navigator | +| Reflection-based routing | **Must use** `SavedStateConfiguration` with polymorphic serialization | +| Direct route access | Key-based entry access | + +**Key considerations:** +- The JetBrains Nav 3 artifact is at **1.0.0-alpha05** (not yet stable for KMP) +- Android-side Nav 3 is stable at 1.0.1 +- We must implement polymorphic serialization for all routes +- Our Koin-based DI can provide serializer modules per feature +- Browser history navigation (for web target) is expected in Nav 3 KMP 1.1.0 + +### 6.6 CMP Nav 3 Recipes + +JetBrains maintains a Compose Multiplatform fork of the nav3-recipes: +- [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes) + +--- + +## 7. Appendix: Version & Dependency Reference + +### AndroidX Dependencies (Android-only) + +```toml +[versions] +nav3Core = "1.0.1" # Stable +# nav3Core = "1.1.0-rc01" # Latest RC +lifecycleViewmodelNav3 = "2.11.0-alpha03" +material3AdaptiveNav3 = "1.3.0-alpha09" +kotlinxSerializationCore = "1.9.0" + +[libraries] +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } +androidx-material3-adaptive-navigation3 = { module = "androidx.compose.material3.adaptive:adaptive-navigation3", version.ref = "material3AdaptiveNav3" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } +``` + +### Compose Multiplatform Dependencies (KMP) + +```toml +[versions] +multiplatform-nav3-ui = "1.0.0-alpha05" +compose-multiplatform-adaptive = "1.3.0-alpha02" +compose-multiplatform-lifecycle = "2.10.0-alpha05" + +[libraries] +jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } +jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } +jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } +``` + +### Nav 3 Recipes Repository + +- Android: [github.com/android/nav3-recipes](https://github.com/android/nav3-recipes) (1.2k stars) +- CMP: [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes) + +Available recipes: +- Basic, Saveable back stack, Entry provider DSL +- Deep links (basic + advanced) +- Dialog, BottomSheet, List-Detail, Two-pane, Supporting Pane +- Animations +- Navigation UI, Multiple back stacks +- Conditional navigation +- Modularized navigation (Hilt + Koin) +- ViewModel patterns (basic, Hilt, Koin, shared) +- Retain values for back stack composables +- Returning results (events + state) +- Fragment/View interop + +### Sources + +- [Navigation 3 Overview](https://developer.android.com/guide/navigation/navigation-3) +- [Navigation 3 Basics](https://developer.android.com/guide/navigation/navigation-3/basics) +- [Navigation 3 Migration Guide](https://developer.android.com/guide/navigation/navigation-3/migration-guide) +- [Navigation 3 Save State](https://developer.android.com/guide/navigation/navigation-3/save-state) +- [Navigation 3 Metadata](https://developer.android.com/guide/navigation/navigation-3/metadata) +- [Navigation 3 Modularize](https://developer.android.com/guide/navigation/navigation-3/modularize) +- [Navigation 3 Scenes](https://developer.android.com/guide/navigation/navigation-3/scenes) +- [Navigation 3 Animations](https://developer.android.com/guide/navigation/navigation-3/animate-destinations) +- [Navigation 3 Entry Decorators](https://developer.android.com/guide/navigation/navigation-3/naventrydecorators) +- [Navigation 3 Get Started](https://developer.android.com/guide/navigation/navigation-3/get-started) +- [Navigation 3 Release Notes](https://developer.android.com/jetpack/androidx/releases/navigation3) +- [Navigation 3 in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html) +- [Compose Multiplatform 1.10.0 Blog Post](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/) +- [Jetpack Navigation 3 is Stable (Blog)](https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html) +- [Announcing Jetpack Navigation 3 (Blog)](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html) +- [nav3-recipes GitHub](https://github.com/android/nav3-recipes) +- [Deep Link Guide](https://github.com/android/nav3-recipes/blob/main/docs/deeplink-guide.md) +- [Shared ViewModel Recipe](https://developer.android.com/guide/navigation/navigation-3/recipes/sharedviewmodel) diff --git a/app/CLAUDE.md b/app/CLAUDE.md index e94e42a..26cee9e 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -4,11 +4,13 @@ Single Activity architecture. `MainActivity` is the sole entry point. ## Navigation -`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`: +`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`: - **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture. - **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar. +Feature modules provide entries via `EntryProviderScope` extensions (e.g., `authEntries()`, `chatEntries()`). Navigation is driven by `onNavigate: (NavKey) -> Unit` and `onBack: () -> Unit` lambdas — feature modules never receive `NavBackStack` directly. + ## Sidebar / Drawer Content `DrawerContent` is the rich sidebar matching the web's left panel: @@ -22,8 +24,8 @@ Single Activity architecture. `MainActivity` is the sole entry point. ## Top-Level Destinations -Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings. -Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost. +Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings). +Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider. ## Active Conversation Tracking @@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a ## Auth & Session - `NavHostViewModel` observes auth state via `isLoggedIn` flow -- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise -- `sessionExpired` flow triggers nav to auth graph with full backstack clear +- Start destination is `NewChat` if logged in, `ServerUrl` otherwise +- `sessionExpired` flow triggers nav to auth flow with full back stack clear - Drawer gestures are hidden during auth flow ## Deep Linking @@ -66,6 +68,6 @@ It applies convention plugins: `librechat.mobile.application`, `librechat.mobile - **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed ### Preset Navigation -- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt` +- `PresetManager` route registered in `SettingsNavigation.kt` - `PresetManagerScreen` accessible from Settings → Chat section -- Navigation wired in both `LibreChatNavHost` and `TabletLayout` +- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 361f549..c9d45c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,7 +31,8 @@ dependencies { implementation(project(":feature:files")) implementation(libs.activity.compose) - implementation(libs.navigation.compose) + implementation(libs.navigation3.ui.kmp) + implementation(libs.lifecycle.viewmodel.navigation3.kmp) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel.navigation) diff --git a/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt b/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt index dfbb34a..9a010d3 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt @@ -51,8 +51,8 @@ class MainActivity : ComponentActivity() { /** * Incremented each time a share intent arrives. - * The NavHost observes this and either: - * - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or + * The NavDisplay observes this and either: + * - Navigates to NewChat if the user is not on a chat screen, or * - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen. */ private var shareNavigationTrigger by mutableStateOf(0) diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt index 48c8385..bd21e90 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt @@ -81,7 +81,7 @@ private val ActiveIndicatorShape = RoundedCornerShape(2.dp) /** * Stateful DrawerContent that collects its own state from the ViewModel. * State changes only recompose inside this composable — not the parent - * PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content. + * PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content. */ @Composable fun DrawerContent( 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 00ee3ed..16705d1 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt @@ -1,7 +1,6 @@ package com.garfiec.librechat.navigation import android.net.Uri -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -9,7 +8,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -29,55 +30,42 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.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 androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import com.garfiec.librechat.MainActivity import com.garfiec.librechat.R import com.garfiec.librechat.core.ui.components.BannerDisplay -import com.garfiec.librechat.feature.agents.navigation.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.agents.navigation.agentsEntries import com.garfiec.librechat.feature.auth.navigation.AuthRoute import com.garfiec.librechat.feature.auth.navigation.ServerUrl -import com.garfiec.librechat.feature.auth.navigation.authGraph +import com.garfiec.librechat.feature.auth.navigation.authEntries 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.chat.navigation.chatEntries import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations -import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph +import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries import com.garfiec.librechat.feature.files.navigation.Files -import com.garfiec.librechat.feature.files.navigation.filesGraph -import com.garfiec.librechat.feature.settings.navigation.ApiKeys -import com.garfiec.librechat.feature.settings.navigation.PresetManager +import com.garfiec.librechat.feature.files.navigation.filesEntries import com.garfiec.librechat.feature.settings.navigation.SettingsAccount import com.garfiec.librechat.feature.settings.navigation.SettingsChat import com.garfiec.librechat.feature.settings.navigation.SettingsData import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral import com.garfiec.librechat.feature.settings.navigation.SettingsRoute import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed -import com.garfiec.librechat.feature.settings.navigation.SharedLinks -import com.garfiec.librechat.feature.settings.navigation.settingsGraph +import com.garfiec.librechat.feature.settings.navigation.memoriesEntry +import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry +import com.garfiec.librechat.feature.settings.navigation.settingsEntries import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import co.touchlab.kermit.Logger -/** 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 @@ -95,46 +83,40 @@ fun LibreChatNavHost( shareNavigationTrigger: Int = 0, navHostViewModel: NavHostViewModel = koinViewModel(), ) { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() - val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) || - currentDestination?.parent.isRoute(AuthRoute::class) + // Stable start key — auth redirect handled via LaunchedEffect below. + val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat) + val navigator = Navigator(backStack) + + // Redirect to auth if not logged in (on first composition only). + LaunchedEffect(Unit) { + if (!navHostViewModel.isLoggedIn.value) { + navigator.navigateToAuth() + } + } // Track active conversation from nav back stack - LaunchedEffect(navBackStackEntry) { - val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) { - navBackStackEntry?.toRoute()?.conversationId - } else { - null - } + LaunchedEffect(navigator.currentRoute) { + val conversationId = (navigator.currentRoute as? Chat)?.conversationId navHostViewModel.setActiveConversation(conversationId) } // Handle session expiry LaunchedEffect(Unit) { navHostViewModel.sessionExpired.collect { - navController.navigate(ServerUrl) { - popUpTo(0) { inclusive = true } - } + navigator.navigateToAuth() } } - val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class - val isTablet = windowSizeClass?.widthSizeClass?.let { it >= WindowWidthSizeClass.Medium } ?: false if (isTablet) { TabletLayout( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, - isInAuthFlow = isInAuthFlow, deepLinkUri = deepLinkUri, onDeepLinkConsumed = onDeepLinkConsumed, shareNavigationTrigger = shareNavigationTrigger, @@ -142,10 +124,8 @@ fun LibreChatNavHost( ) } else { PhoneLayout( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, - isInAuthFlow = isInAuthFlow, deepLinkUri = deepLinkUri, onDeepLinkConsumed = onDeepLinkConsumed, shareNavigationTrigger = shareNavigationTrigger, @@ -206,10 +186,8 @@ private fun VersionMismatchDialog( @Composable private fun PhoneLayout( - navController: androidx.navigation.NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: Any, - isInAuthFlow: Boolean, deepLinkUri: Uri?, onDeepLinkConsumed: () -> Unit, shareNavigationTrigger: Int, @@ -217,7 +195,6 @@ private fun PhoneLayout( ) { val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - // Banner state only -- drawer state is collected inside DrawerContent itself val banners by navHostViewModel.banners.collectAsStateWithLifecycle() val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() @@ -227,7 +204,7 @@ private fun PhoneLayout( if (uri.scheme == "librechat" && uri.host == "conversation") { uri.lastPathSegment?.let { conversationId -> if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) } else { Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } } @@ -237,22 +214,14 @@ private fun PhoneLayout( } } - // Handle share intent: if already on a chat screen, let the active ChatViewModel - // consume the shared content reactively. Only navigate to new chat when on a - // non-chat screen (e.g. settings, agents, files). + // Handle share intent LaunchedEffect(shareNavigationTrigger) { if (shareNavigationTrigger > 0) { - val currentDest = navController.currentBackStackEntry?.destination - val isOnChatScreen = currentDest.isRoute(Chat::class) || - currentDest.isRoute(NewChat::class) + val currentRoute = navigator.currentRoute + val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat if (!isOnChatScreen) { - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } + navigator.navigateToTopLevel(NewChat) } - // If already on a chat screen, SharedIntentConsumer.shareAvailable - // will notify the active ChatViewModel to consume the content. } } @@ -265,57 +234,42 @@ private fun PhoneLayout( ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = !isInAuthFlow, + gesturesEnabled = !navigator.isInAuthFlow, drawerContent = { ModalDrawerSheet { SidebarScaffold( viewModel = navHostViewModel, onNewChat = { scope.launch { drawerState.close() } - // Skip navigation if already on the new chat screen - val currentDest = navController.currentBackStackEntry?.destination - if (!currentDest.isRoute(NewChat::class)) { - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } + if (navigator.currentRoute !is NewChat) { + navigator.navigateToTopLevel(NewChat) } }, onConversationClick = { conversationId -> scope.launch { drawerState.close() } - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) }, onSettingsClick = { scope.launch { drawerState.close() } - navController.navigate(SettingsTabbed) { - launchSingleTop = true - } + navigator.navigate(SettingsTabbed) }, onSettingsCategorySelected = { category -> - // Navigate to the settings sub-page but keep the drawer open - navController.navigate(category.toRoute()) { - launchSingleTop = true - } + navigator.navigate(category.toRoute()) }, onAgentsClick = { scope.launch { drawerState.close() } - navController.navigate(AgentMarketplace) { - launchSingleTop = true - } + navigator.navigate(AgentMarketplace) }, onFilesClick = { scope.launch { drawerState.close() } - navController.navigate(Files) { - launchSingleTop = true - } + navigator.navigate(Files) }, ) } }, ) { - // No bottom bar -- drawer is the primary navigation Column(modifier = modifier.fillMaxSize()) { - if (!isInAuthFlow && banners.isNotEmpty()) { + if (!navigator.isInAuthFlow && banners.isNotEmpty()) { BannerDisplay( banners = banners, dismissedIds = dismissedBannerIds, @@ -323,122 +277,100 @@ private fun PhoneLayout( modifier = Modifier.padding(top = 4.dp), ) } - NavHost( - navController = navController, - startDestination = startDestination, + MainNavDisplay( + navigator = navigator, + navHostViewModel = navHostViewModel, + onOpenDrawer = { scope.launch { drawerState.open() } }, modifier = Modifier.fillMaxSize(), - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(300), - ) - }, - exitTransition = { - fadeOut(animationSpec = tween(150)) - }, - popEnterTransition = { - fadeIn( - initialAlpha = 0.5f, - animationSpec = tween(300, easing = LinearEasing), - ) + scaleIn( - initialScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - popExitTransition = { - slideOut( - targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - ) { - authGraph( - navController = navController, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navController.navigate(NewChat) { - popUpTo { inclusive = true } - } - }, - ) - chatGraph( - navController = navController, - onOpenDrawer = { - scope.launch { drawerState.open() } - }, - ) - conversationsGraph( - onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) - }, - onNavigateToArchived = { - navController.navigate(ArchivedConversations) - }, - onNavigateBackFromArchived = { - navController.popBackStack() - }, - ) - agentsGraph( - onAgentClick = { agentId -> - navController.navigate(AgentDetail(agentId = agentId)) - }, - onBack = { navController.popBackStack() }, - onStartChat = { agentId -> - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } - }, - onCreateAgent = { - navController.navigate(AgentEditorCreate) - }, - onEditAgent = { agentId -> - navController.navigate(AgentEditorEdit(agentId = agentId)) - }, - ) - filesGraph() - settingsGraph( - onLogout = { - navHostViewModel.logout() - navController.navigate(ServerUrl) { - popUpTo(0) { inclusive = true } - } - }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToArchived = { - navController.navigate(ArchivedConversations) { - launchSingleTop = true - } - }, - onNavigateToSharedLinks = { - navController.navigate(SharedLinks) { - launchSingleTop = true - } - }, - onNavigateBackFromSharedLinks = { - navController.popBackStack() - }, - onNavigateToPresets = { - navController.navigate(PresetManager) { - launchSingleTop = true - } - }, - onNavigateBackFromPresets = { - navController.popBackStack() - }, - onNavigateToApiKeys = { - navController.navigate(ApiKeys) { - launchSingleTop = true - } - }, - onNavigateBackFromApiKeys = { - navController.popBackStack() - }, - ) - } + ) } } } + +@Composable +internal fun MainNavDisplay( + navigator: Navigator, + navHostViewModel: NavHostViewModel, + onOpenDrawer: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + modifier = modifier, + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(150)) + }, + popTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + predictivePopTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + authEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navigator.navigateToChat() + }, + ) + chatEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onNavigateToChat = { navigator.navigateToChat(it) }, + onOpenDrawer = onOpenDrawer, + ) + conversationsEntries( + onConversationClick = { navigator.navigateToChat(it) }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + onBack = { navigator.goBack() }, + ) + agentsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onStartChat = { _ -> navigator.navigateToTopLevel(NewChat) }, + ) + filesEntries( + onBack = { navigator.goBack() }, + ) + settingsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onLogout = { + navHostViewModel.logout() + navigator.navigateToAuth() + }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + ) + memoriesEntry( + onBack = { navigator.goBack() }, + ) + mcpServersEntry( + onBack = { navigator.goBack() }, + ) + }, + ) +} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt index be92769..e82989e 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt @@ -1,5 +1,6 @@ package com.garfiec.librechat.navigation +import androidx.savedstate.serialization.SavedStateConfiguration import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule import com.garfiec.librechat.feature.auth.navigation.authSerializersModule import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule @@ -12,7 +13,7 @@ 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. + * Used by Nav 3's SavedStateConfiguration for type-safe state saving. */ val navigationSerializersModule: SerializersModule = authSerializersModule + @@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule = agentsSerializersModule + filesSerializersModule + settingsSerializersModule + +/** + * Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types. + * Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms. + */ +val navigationSavedStateConfig = SavedStateConfiguration { + serializersModule = navigationSerializersModule +} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt new file mode 100644 index 0000000..a01a2da --- /dev/null +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt @@ -0,0 +1,60 @@ +package com.garfiec.librechat.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.feature.auth.navigation.AuthRoute +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat + +/** + * Encapsulates all back stack mutations for Nav 3 navigation. + * Follows UDF: UI events → Navigator → back stack state → NavDisplay recomposition. + */ +class Navigator(val backStack: NavBackStack) { + + val currentRoute: NavKey? get() = backStack.lastOrNull() + + val isInAuthFlow: Boolean get() = currentRoute is AuthRoute + + /** Navigate forward to a route. */ + fun navigate(route: NavKey) { + backStack.add(route) + } + + /** Pop the top entry. Safe on empty stacks. */ + fun goBack() { + backStack.removeLastOrNull() + } + + /** Navigate to a chat, replacing any current chat on the stack. */ + fun navigateToChat(conversationId: String) { + if (backStack.lastOrNull() is Chat) { + backStack.removeLastOrNull() + } + backStack.add(Chat(conversationId)) + } + + /** Navigate to a top-level destination, popping to root first. */ + fun navigateToTopLevel(route: NavKey) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + if (backStack.lastOrNull()?.let { it::class } != route::class) { + backStack.removeLastOrNull() + backStack.add(route) + } + } + + /** Clear back stack and navigate to auth (session expiry / logout). */ + fun navigateToAuth() { + backStack.clear() + backStack.add(ServerUrl) + } + + /** Clear back stack and navigate to chat (auth complete). */ + fun navigateToChat() { + backStack.clear() + backStack.add(NewChat) + } +} 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 46bb647..56af52a 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt @@ -2,18 +2,9 @@ package com.garfiec.librechat.navigation import android.net.Uri import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideOut import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -34,46 +25,19 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavDestination -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import kotlin.reflect.KClass +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.MainActivity import com.garfiec.librechat.core.ui.components.BannerDisplay -import com.garfiec.librechat.feature.agents.navigation.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.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 -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.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. */ @@ -81,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f @Composable fun TabletLayout( - navController: NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: Any, - isInAuthFlow: Boolean, deepLinkUri: Uri?, onDeepLinkConsumed: () -> Unit, modifier: Modifier = Modifier, @@ -95,8 +57,6 @@ fun TabletLayout( val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() // Persisted sidebar state from DataStore -- single source of truth in the ViewModel. - // The ViewModel's StateFlow initial value is read synchronously from DataStore, - // so the first composition already has the correct persisted state (no flicker). val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle() // Whether swipe gesture is enabled (from settings) @@ -106,7 +66,6 @@ fun TabletLayout( val sidebarWidthPx = with(density) { SidebarWidth.toPx() } // Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open. - // During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx. val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) } val scope = rememberCoroutineScope() @@ -121,7 +80,7 @@ fun TabletLayout( if (uri.scheme == "librechat" && uri.host == "conversation") { uri.lastPathSegment?.let { conversationId -> if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) } else { Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } } @@ -131,19 +90,13 @@ fun TabletLayout( } } - // Handle share intent: if already on a chat screen, let the active ChatViewModel - // consume the shared content reactively. Only navigate to new chat when on a - // non-chat screen (e.g. settings, agents, files). + // Handle share intent LaunchedEffect(shareNavigationTrigger) { if (shareNavigationTrigger > 0) { - val currentDest = navController.currentBackStackEntry?.destination - val isOnChatScreen = currentDest.isRoute(Chat::class) || - currentDest.isRoute(NewChat::class) + val currentRoute = navigator.currentRoute + val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat if (!isOnChatScreen) { - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } + navigator.navigateToTopLevel(NewChat) } } } @@ -163,7 +116,7 @@ fun TabletLayout( } } - if (!isInAuthFlow) { + if (!navigator.isInAuthFlow) { val swipeModifier = if (gestureEnabled) { Modifier.pointerInput(Unit) { val velocityTracker = VelocityTracker() @@ -174,7 +127,6 @@ fun TabletLayout( onDragEnd = { val velocity = velocityTracker.calculateVelocity().x val currentOffset = sidebarOffset.value - // Decide target: fling velocity wins, otherwise use 50% threshold val shouldOpen = when { velocity > FLING_VELOCITY_THRESHOLD -> true velocity < -FLING_VELOCITY_THRESHOLD -> false @@ -192,7 +144,6 @@ fun TabletLayout( } }, onDragCancel = { - // Snap back to the current committed state val target = if (isSidebarOpen) sidebarWidthPx else 0f scope.launch { sidebarOffset.animateTo( @@ -229,37 +180,24 @@ fun TabletLayout( SidebarScaffold( viewModel = navHostViewModel, onNewChat = { - // Skip navigation if already on the new chat screen - val currentDest = navController.currentBackStackEntry?.destination - if (!currentDest.isRoute(NewChat::class)) { - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } + if (navigator.currentRoute !is NewChat) { + navigator.navigateToTopLevel(NewChat) } }, onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) }, onSettingsClick = { - navController.navigate(SettingsTabbed) { - launchSingleTop = true - } + navigator.navigate(SettingsTabbed) }, onSettingsCategorySelected = { category -> - navController.navigate(category.toRoute()) { - launchSingleTop = true - } + navigator.navigate(category.toRoute()) }, onAgentsClick = { - navController.navigate(AgentMarketplace) { - launchSingleTop = true - } + navigator.navigate(AgentMarketplace) }, onFilesClick = { - navController.navigate(Files) { - launchSingleTop = true - } + navigator.navigate(Files) }, modifier = Modifier .width(SidebarWidth) @@ -271,9 +209,8 @@ fun TabletLayout( } // Slot 1: Main content -- resizes to fill remaining space MainContent( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, isInAuthFlow = false, banners = banners, dismissedBannerIds = dismissedBannerIds, @@ -287,29 +224,24 @@ fun TabletLayout( val sidebarPx = SidebarWidth.roundToPx() val animatedPx = sidebarOffset.value.toInt() - // Sidebar: always measured at full 320dp width val sidebarPlaceable = measurables[0].measure( constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx), ) - // Main content: resizes to fill screen minus the visible sidebar portion val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0) val mainPlaceable = measurables[1].measure( constraints.copy(minWidth = mainWidth, maxWidth = mainWidth), ) layout(constraints.maxWidth, constraints.maxHeight) { - // Sidebar slides: -320px (off-screen) -> 0px (fully visible) sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0) - // Main content starts right after the visible sidebar portion mainPlaceable.placeRelative(animatedPx, 0) } } } else { // Auth flow -- no sidebar, just main content MainContent( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, isInAuthFlow = true, banners = banners, dismissedBannerIds = dismissedBannerIds, @@ -321,9 +253,8 @@ fun TabletLayout( @Composable private fun MainContent( - navController: NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: Any, isInAuthFlow: Boolean, banners: List, dismissedBannerIds: Set, @@ -339,119 +270,11 @@ private fun MainContent( modifier = Modifier.padding(top = 4.dp), ) } - NavHost( - navController = navController, - startDestination = startDestination, + MainNavDisplay( + navigator = navigator, + navHostViewModel = navHostViewModel, + onOpenDrawer = onToggleDrawer, modifier = Modifier.fillMaxSize(), - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(300), - ) - }, - exitTransition = { - fadeOut(animationSpec = tween(150)) - }, - popEnterTransition = { - fadeIn( - initialAlpha = 0.5f, - animationSpec = tween(300, easing = LinearEasing), - ) + scaleIn( - initialScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - popExitTransition = { - slideOut( - targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - ) { - authGraph( - navController = navController, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navController.navigate(NewChat) { - popUpTo { inclusive = true } - } - }, - ) - chatGraph( - navController = navController, - onOpenDrawer = onToggleDrawer, - ) - conversationsGraph( - onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) - }, - onNavigateToArchived = { - navController.navigate(ArchivedConversations) - }, - onNavigateBackFromArchived = { - navController.popBackStack() - }, - ) - agentsGraph( - onAgentClick = { agentId -> - navController.navigate(AgentDetail(agentId = agentId)) - }, - onBack = { navController.popBackStack() }, - onStartChat = { agentId -> - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } - }, - onCreateAgent = { - navController.navigate(AgentEditorCreate) - }, - onEditAgent = { agentId -> - navController.navigate(AgentEditorEdit(agentId = agentId)) - }, - ) - filesGraph() - settingsGraph( - onLogout = { - navHostViewModel.logout() - navController.navigate(ServerUrl) { - popUpTo(0) { inclusive = true } - } - }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToArchived = { - navController.navigate(ArchivedConversations) { - launchSingleTop = true - } - }, - onNavigateToSharedLinks = { - navController.navigate(SharedLinks) { - launchSingleTop = true - } - }, - onNavigateBackFromSharedLinks = { - navController.popBackStack() - }, - onNavigateToPresets = { - navController.navigate(PresetManager) { - launchSingleTop = true - } - }, - onNavigateBackFromPresets = { - navController.popBackStack() - }, - onNavigateToApiKeys = { - navController.navigate(ApiKeys) { - launchSingleTop = true - } - }, - onNavigateBackFromApiKeys = { - navController.popBackStack() - }, - ) - } + ) } } diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/TopLevelDestination.kt deleted file mode 100644 index 814a1a0..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/TopLevelDestination.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.garfiec.librechat.navigation - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Chat -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.ui.graphics.vector.ImageVector - -/** - * Destinations accessible from the navigation drawer footer. - * The primary navigation is now sidebar-first (drawer with conversation list), - * matching the web frontend pattern. Bottom nav has been removed entirely. - * - * Conversations are integrated into the drawer body (not a separate destination). - * Agents and Files are accessible via drawer footer links. - * Settings are accessed through the sidebar settings nav menu (see [SettingsCategory]). - */ -enum class TopLevelDestination( - val route: String, - val icon: ImageVector, - val label: String, -) { - CHAT("chat_graph", Icons.Default.Chat, "Chat"), - CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"), - AGENTS("agents", Icons.Default.SmartToy, "Agents"), - FILES("files", Icons.Default.Folder, "Files"), -} diff --git a/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt b/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt new file mode 100644 index 0000000..1898a16 --- /dev/null +++ b/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt @@ -0,0 +1,138 @@ +package com.garfiec.librechat.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace +import com.garfiec.librechat.feature.auth.navigation.Login +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat +import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NavigatorTest { + + private fun createNavigator(vararg keys: NavKey): Navigator { + return Navigator(NavBackStack(*keys)) + } + + @Test + fun `currentRoute returns last entry`() { + val navigator = createNavigator(NewChat) + assertEquals(NewChat, navigator.currentRoute) + } + + @Test + fun `currentRoute returns null for empty stack`() { + val navigator = createNavigator() + assertNull(navigator.currentRoute) + } + + @Test + fun `isInAuthFlow returns true for auth routes`() { + val navigator = createNavigator(ServerUrl) + assertTrue(navigator.isInAuthFlow) + } + + @Test + fun `isInAuthFlow returns true for nested auth routes`() { + val navigator = createNavigator(ServerUrl, Login) + assertTrue(navigator.isInAuthFlow) + } + + @Test + fun `isInAuthFlow returns false for non-auth routes`() { + val navigator = createNavigator(NewChat) + assertFalse(navigator.isInAuthFlow) + } + + @Test + fun `navigate adds route to back stack`() { + val navigator = createNavigator(NewChat) + navigator.navigate(SettingsTabbed) + assertEquals(listOf(NewChat, SettingsTabbed), navigator.backStack.toList()) + } + + @Test + fun `goBack removes top entry`() { + val navigator = createNavigator(NewChat, SettingsTabbed) + navigator.goBack() + assertEquals(listOf(NewChat), navigator.backStack.toList()) + } + + @Test + fun `goBack on empty stack does not crash`() { + val navigator = createNavigator() + navigator.goBack() + assertTrue(navigator.backStack.isEmpty()) + } + + @Test + fun `navigateToChat with conversationId adds Chat route`() { + val navigator = createNavigator(NewChat) + navigator.navigateToChat("conv-123") + assertEquals( + listOf(NewChat, Chat(conversationId = "conv-123")), + navigator.backStack.toList(), + ) + } + + @Test + fun `navigateToChat replaces current chat`() { + val navigator = createNavigator(NewChat, Chat("conv-1")) + navigator.navigateToChat("conv-2") + assertEquals( + listOf(NewChat, Chat(conversationId = "conv-2")), + navigator.backStack.toList(), + ) + } + + @Test + fun `navigateToChat does not replace non-chat route`() { + val navigator = createNavigator(NewChat, SettingsTabbed) + navigator.navigateToChat("conv-1") + assertEquals( + listOf(NewChat, SettingsTabbed, Chat(conversationId = "conv-1")), + navigator.backStack.toList(), + ) + } + + @Test + fun `navigateToTopLevel pops to root and replaces`() { + val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed) + navigator.navigateToTopLevel(AgentMarketplace) + assertEquals(listOf(AgentMarketplace), navigator.backStack.toList()) + } + + @Test + fun `navigateToTopLevel with same route does not duplicate`() { + val navigator = createNavigator(NewChat) + navigator.navigateToTopLevel(NewChat) + assertEquals(listOf(NewChat), navigator.backStack.toList()) + } + + @Test + fun `navigateToTopLevel replaces different root`() { + val navigator = createNavigator(NewChat) + navigator.navigateToTopLevel(AgentMarketplace) + assertEquals(listOf(AgentMarketplace), navigator.backStack.toList()) + } + + @Test + fun `navigateToAuth clears stack and adds ServerUrl`() { + val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed) + navigator.navigateToAuth() + assertEquals(listOf(ServerUrl), navigator.backStack.toList()) + } + + @Test + fun `navigateToChat no-arg clears stack and adds NewChat`() { + val navigator = createNavigator(ServerUrl, Login) + navigator.navigateToChat() + assertEquals(listOf(NewChat), navigator.backStack.toList()) + } +} diff --git a/build-logic/CLAUDE.md b/build-logic/CLAUDE.md index 1354b7b..49361f2 100644 --- a/build-logic/CLAUDE.md +++ b/build-logic/CLAUDE.md @@ -2,16 +2,31 @@ Convention plugins that apply consistent Gradle configuration across all modules. -## Convention Plugins (7 total) +## Convention Plugins (13 total) +### Android | Plugin ID | Class | What it does | |-----------|-------|-------------| -| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 | +| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 36, minSdk 26, JDK 17 | | `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config | | `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 | | `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies | -| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization | +| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization + Nav 3 | | `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config | +| `librechat.mobile.detekt` | `AndroidDetektConventionPlugin` | Detekt static analysis | + +### KMP +| Plugin ID | Class | What it does | +|-----------|-------|-------------| +| `librechat.kmp.library` | `KmpLibraryConventionPlugin` | KMP library setup (Android + iOS targets) | +| `librechat.kmp.compose` | `KmpComposeConventionPlugin` | Compose Multiplatform + Material 3 | +| `librechat.kmp.koin` | `KmpKoinConventionPlugin` | Koin multiplatform dependencies | +| `librechat.kmp.feature` | `KmpFeatureConventionPlugin` | Auto-applies: kmp library + compose + koin + serialization + Nav 3 | +| `librechat.kmp.room` | `KmpRoomConventionPlugin` | Room multiplatform + KSP | + +### Shared +| Plugin ID | Class | What it does | +|-----------|-------|-------------| | `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency | ## Critical: apply() Signature @@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs. ## Feature Module Convention -Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose, -koin, and serialization. They only need to add feature-specific dependencies. +Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose, +koin, serialization, and Nav 3. They only need to add feature-specific dependencies. Feature modules depend on `:core:*` only, never on other feature modules. diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index 0c1f787..f69a446 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -17,7 +17,8 @@ class AndroidFeatureConventionPlugin : Plugin { add("implementation", libs.findLibrary("koin-compose").get()) add("implementation", libs.findLibrary("koin-compose-viewmodel").get()) add("implementation", libs.findLibrary("koin-compose-viewmodel-navigation").get()) - add("implementation", libs.findLibrary("navigation-compose").get()) + add("implementation", libs.findLibrary("navigation3-ui-kmp").get()) + add("implementation", libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get()) add("implementation", libs.findBundle("lifecycle").get()) } } diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index 16d2efa..6dc95d2 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -20,9 +20,10 @@ class KmpFeatureConventionPlugin : Plugin { implementation(libs.findLibrary("koin-compose").get()) implementation(libs.findLibrary("koin-compose-viewmodel").get()) implementation(libs.findLibrary("koin-compose-viewmodel-navigation").get()) - implementation(libs.findLibrary("navigation-compose-kmp").get()) + implementation(libs.findLibrary("navigation3-ui-kmp").get()) implementation(libs.findLibrary("lifecycle-runtime-compose-kmp").get()) implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get()) + implementation(libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get()) } } } diff --git a/core/ui/CLAUDE.md b/core/ui/CLAUDE.md index 00d2822..bd8eb85 100644 --- a/core/ui/CLAUDE.md +++ b/core/ui/CLAUDE.md @@ -73,7 +73,7 @@ These rules apply to ALL composables across feature modules, not just core:ui. 3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types. -4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavHost. State changes should only recompose the composable that reads them. +4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavDisplay. State changes should only recompose the composable that reads them. 5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame). diff --git a/core/ui/src/androidMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt b/core/ui/src/androidMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt deleted file mode 100644 index 617fdad..0000000 --- a/core/ui/src/androidMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.garfiec.librechat.core.ui.components - -import android.os.Build -import android.view.RoundedCorner -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp - -private const val FALLBACK_CORNER_RADIUS_DP = 24f - -/** - * Wraps screen content to apply rounded corners during navigation transitions. - * Pass the [transition] from AnimatedVisibilityScope inside a composable() block. - */ -@Composable -actual fun ScreenTransitionWrapper( - transition: Transition, - modifier: Modifier, - content: @Composable () -> Unit, -) { - val screenCornerRadiusDp = rememberScreenCornerRadiusDp() - - val cornerRadius by transition.animateFloat( - transitionSpec = { tween(300, easing = LinearEasing) }, - label = "screenCorner", - ) { state -> - when (state) { - EnterExitState.PreEnter -> screenCornerRadiusDp - EnterExitState.Visible -> 0f - EnterExitState.PostExit -> screenCornerRadiusDp - } - } - Box( - modifier = modifier - .fillMaxSize() - .clip(RoundedCornerShape(cornerRadius.dp)), - ) { - content() - } -} - -@Composable -private fun rememberScreenCornerRadiusDp(): Float { - val view = LocalView.current - val density = LocalDensity.current - return remember(view, density) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val insets = view.rootWindowInsets - val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT) - if (topLeft != null && topLeft.radius > 0) { - with(density) { topLeft.radius.toDp().value } - } else { - FALLBACK_CORNER_RADIUS_DP - } - } else { - FALLBACK_CORNER_RADIUS_DP - } - } -} diff --git a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt deleted file mode 100644 index 21e5798..0000000 --- a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.garfiec.librechat.core.ui.components - -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.core.Transition -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * Wraps screen content to apply rounded corners during navigation transitions. - * On Android, reads the device screen corner radius for a polished effect. - * On iOS, renders content directly (no transition animation needed). - */ -@Composable -expect fun ScreenTransitionWrapper( - transition: Transition, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) diff --git a/core/ui/src/iosMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.ios.kt b/core/ui/src/iosMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.ios.kt deleted file mode 100644 index 13fd3aa..0000000 --- a/core/ui/src/iosMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.ios.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.garfiec.librechat.core.ui.components - -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer - -@Composable -actual fun ScreenTransitionWrapper( - transition: Transition, - modifier: Modifier, - content: @Composable () -> Unit, -) { - val alpha by transition.animateFloat( - transitionSpec = { tween(300, easing = LinearEasing) }, - label = "screenAlpha", - ) { state -> - when (state) { - EnterExitState.PreEnter -> 0f - EnterExitState.Visible -> 1f - EnterExitState.PostExit -> 0f - } - } - Box( - modifier = modifier - .fillMaxSize() - .graphicsLayer { this.alpha = alpha }, - ) { - content() - } -} diff --git a/feature/agents/CLAUDE.md b/feature/agents/CLAUDE.md index b5965eb..3376aef 100644 --- a/feature/agents/CLAUDE.md +++ b/feature/agents/CLAUDE.md @@ -5,8 +5,9 @@ - **AgentDetailScreen** -- full agent info with "Start Chat" action ## Navigation -- `AGENTS_ROUTE` ("agents") -> marketplace grid -- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view +- Sealed interface: `AgentsRoute : NavKey` with typed route classes +- Routes: `AgentMarketplace` (grid), `AgentDetail(agentId: String)` (detail), `AgentEditorCreate`, `AgentEditorEdit(agentId: String)` (all `@Serializable`) +- Feature entries registered via `EntryProviderScope.agentsEntries()` - `onStartChat(agentId)` callback navigates to chat with the selected agent ## Marketplace ViewModel diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt index a06dd3b..71b226b 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt @@ -14,14 +14,22 @@ val agentsModule = module { includes(agentsPlatformModule) viewModelOf(::AgentMarketplaceViewModel) - viewModelOf(::AgentDetailViewModel) - viewModel { + viewModel { params -> + AgentDetailViewModel( + savedStateHandle = get(), + agentRepository = get(), + serverDataStore = get(), + initialAgentId = params.getOrNull(), + ) + } + viewModel { params -> AgentEditorViewModel( savedStateHandle = get(), agentRepository = get(), configRepository = get(), mcpRepository = get(), contentReader = get(), + initialAgentId = params.getOrNull(), ) } } 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 52b92bf..32566ff 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,7 +1,7 @@ package com.garfiec.librechat.feature.agents.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen @@ -10,51 +10,52 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass -@Serializable sealed interface AgentsRoute +@Serializable sealed interface AgentsRoute : NavKey @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, +fun EntryProviderScope.agentsEntries( + onNavigate: (NavKey) -> Unit, onBack: () -> Unit, onStartChat: (String) -> Unit, - onCreateAgent: () -> Unit, - onEditAgent: (String) -> Unit, ) { - composable { + entry { AgentMarketplaceScreen( - onAgentClick = onAgentClick, - onCreateAgent = onCreateAgent, + onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + onCreateAgent = { onNavigate(AgentEditorCreate) }, onBack = onBack, ) } - composable { + entry { key -> AgentDetailScreen( onBack = onBack, onStartChat = onStartChat, - onEdit = onEditAgent, - onDuplicated = onAgentClick, + onEdit = { agentId -> onNavigate(AgentEditorEdit(agentId = agentId)) }, + onDuplicated = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + agentId = key.agentId, ) } - composable { + entry { AgentEditorScreen( onBack = onBack, - onSaved = { agentId -> onAgentClick(agentId) }, + onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + agentId = null, ) } - composable { + entry { key -> AgentEditorScreen( onBack = onBack, - onSaved = { agentId -> onAgentClick(agentId) }, + onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + agentId = key.agentId, ) } } val agentsSerializersModule = SerializersModule { - polymorphic(AgentsRoute::class) { + polymorphic(NavKey::class) { subclass(AgentMarketplace::class, AgentMarketplace.serializer()) subclass(AgentDetail::class, AgentDetail.serializer()) subclass(AgentEditorCreate::class, AgentEditorCreate.serializer()) diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt index 45e0c7c..3e4ab1a 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt @@ -56,6 +56,7 @@ import librechat_mobile.feature.agents.generated.resources.* import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailEvent import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -65,8 +66,9 @@ fun AgentDetailScreen( onStartChat: (String) -> Unit, onEdit: (String) -> Unit, onDuplicated: (String) -> Unit, + agentId: String? = null, modifier: Modifier = Modifier, - viewModel: AgentDetailViewModel = koinViewModel(), + viewModel: AgentDetailViewModel = koinViewModel { parametersOf(agentId) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt index 00eb10f..ecc98f4 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt @@ -71,16 +71,18 @@ import com.garfiec.librechat.feature.agents.components.ToolSelectDialog import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorEvent import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentEditorScreen( onBack: () -> Unit, onSaved: (String) -> Unit, + agentId: String? = null, modifier: Modifier = Modifier, onDeleted: () -> Unit = onBack, onDuplicated: (String) -> Unit = onSaved, - viewModel: AgentEditorViewModel = koinViewModel(), + viewModel: AgentEditorViewModel = koinViewModel { parametersOf(agentId) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showToolDialog by rememberSaveable { mutableStateOf(false) } diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt index 0de20b2..6eaa69d 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt @@ -36,9 +36,10 @@ class AgentDetailViewModel( savedStateHandle: SavedStateHandle, private val agentRepository: AgentRepository, private val serverDataStore: ServerDataStore, + initialAgentId: String? = null, ) : ViewModel() { - private val agentId: String = checkNotNull(savedStateHandle["agentId"]) + private val agentId: String = checkNotNull(initialAgentId) { "agentId must be provided" } private val _uiState = MutableStateFlow(AgentDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt index b339133..0078e00 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt @@ -107,9 +107,10 @@ class AgentEditorViewModel( private val configRepository: ConfigRepository, private val mcpRepository: McpRepository, private val contentReader: ContentReader, + initialAgentId: String? = null, ) : ViewModel() { - private val editAgentId: String? = savedStateHandle["agentId"] + private val editAgentId: String? = initialAgentId private val _uiState = MutableStateFlow( AgentEditorUiState( diff --git a/feature/auth/CLAUDE.md b/feature/auth/CLAUDE.md index 556767b..acf4e25 100644 --- a/feature/auth/CLAUDE.md +++ b/feature/auth/CLAUDE.md @@ -8,10 +8,12 @@ - **TwoFactorScreen** -- 6-digit OTP input with backup code fallback ## Navigation -- Graph route: `AUTH_GRAPH_ROUTE` ("auth_graph"), start destination: `SERVER_URL_ROUTE` -- Flow: ServerUrl -> Login -> (2FA if `tempToken` returned) -> `onAuthComplete` +- Sealed interface: `AuthRoute : NavKey` with typed route classes +- Routes: `ServerUrl`, `Login`, `Register`, `ForgotPassword`, `TwoFactor(tempToken)`, `VerifyEmail(email)`, `ResetPassword(userId, token)`, `Terms` (all `@Serializable`) +- Feature entries registered via `EntryProviderScope.authEntries()` +- Flow: `ServerUrl` → `Login` → (2FA if `tempToken` returned) → `onAuthComplete` - Register and ForgotPassword are lateral routes from Login -- `TWO_FACTOR_ROUTE` takes `{tempToken}` nav argument +- `TwoFactor(val tempToken: String)` data class carries the nav argument directly ## OAuth Flow - `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}` @@ -37,7 +39,7 @@ ### Terms Screen - `TermsScreen` + `TermsViewModel` — displays server terms, "I Accept" button -- Route: `TERMS_ROUTE = "auth/terms"` in `AuthNavigation.kt` +- Route: `Terms` data object (part of `AuthRoute` sealed interface) in `AuthNavigation.kt` - Loads terms text via `UserRepository`, posts acceptance on confirm - `TermsViewModel.consumeAccepted()` resets navigation trigger - **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt index 789f2e3..27d7a6e 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt @@ -9,6 +9,7 @@ import com.garfiec.librechat.feature.auth.viewmodel.TermsViewModel import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module @@ -19,9 +20,28 @@ val authModule = module { viewModelOf(::ServerUrlViewModel) viewModelOf(::LoginViewModel) viewModelOf(::RegisterViewModel) - viewModelOf(::VerifyEmailViewModel) + viewModel { params -> + VerifyEmailViewModel( + savedStateHandle = get(), + userRepository = get(), + initialEmail = params.getOrNull(), + ) + } viewModelOf(::ForgotPasswordViewModel) - viewModelOf(::ResetPasswordViewModel) - viewModelOf(::TwoFactorViewModel) + viewModel { params -> + ResetPasswordViewModel( + savedStateHandle = get(), + authRepository = get(), + initialUserId = params.getOrNull(), + initialToken = params.getOrNull(), + ) + } + viewModel { params -> + TwoFactorViewModel( + savedStateHandle = get(), + authRepository = get(), + initialTempToken = params.getOrNull(), + ) + } viewModelOf(::TermsViewModel) } 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 196ffb4..1a81623 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 @@ -1,10 +1,7 @@ package com.garfiec.librechat.feature.auth.navigation -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen import com.garfiec.librechat.feature.auth.screen.LoginScreen import com.garfiec.librechat.feature.auth.screen.RegisterScreen @@ -18,7 +15,7 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass -@Serializable sealed interface AuthRoute +@Serializable sealed interface AuthRoute : NavKey @Serializable data object ServerUrl : AuthRoute @Serializable data object Login : AuthRoute @@ -29,90 +26,69 @@ import kotlinx.serialization.modules.subclass @Serializable data object Terms : AuthRoute @Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute -fun NavController.navigateToVerifyEmail(email: String) { - navigate(VerifyEmail(email = email)) -} - -fun NavController.navigateToResetPassword(userId: String, token: String) { - navigate(ResetPassword(userId = userId, token = token)) -} - -fun NavGraphBuilder.authGraph( - navController: NavController, +fun EntryProviderScope.authEntries( + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, onAuthComplete: () -> Unit, ) { - navigation(startDestination = ServerUrl::class) { - composable { - ScreenTransitionWrapper(transition) { - ServerUrlScreen( - onServerValidated = { navController.navigate(Login) }, - ) - } - } - composable { - ScreenTransitionWrapper(transition) { - LoginScreen( - onLoginSuccess = onAuthComplete, - onNavigateToRegister = { navController.navigate(Register) }, - onNavigateToForgotPassword = { navController.navigate(ForgotPassword) }, - onNavigateToTwoFactor = { tempToken -> - navController.navigate(TwoFactor(tempToken = tempToken)) - }, - ) - } - } - composable { - ScreenTransitionWrapper(transition) { - RegisterScreen( - onRegistered = { navController.popBackStack(inclusive = false) }, - onNavigateToLogin = { navController.popBackStack() }, - ) - } - } - composable { - ScreenTransitionWrapper(transition) { - ForgotPasswordScreen( - onBack = { navController.popBackStack() }, - ) - } - } - composable { - ScreenTransitionWrapper(transition) { - TwoFactorScreen( - onVerified = onAuthComplete, - onBack = { navController.popBackStack() }, - ) - } - } - composable { - ScreenTransitionWrapper(transition) { - VerifyEmailScreen( - onVerified = { navController.popBackStack(inclusive = false) }, - onBack = { navController.popBackStack() }, - ) - } - } - composable { - ScreenTransitionWrapper(transition) { - TermsScreen( - onAccepted = onAuthComplete, - onBack = { navController.popBackStack() }, - ) - } - } - composable { - ScreenTransitionWrapper(transition) { - ResetPasswordScreen( - onResetComplete = { navController.popBackStack(inclusive = false) }, - onBack = { navController.popBackStack() }, - ) - } - } + entry { + ServerUrlScreen( + onServerValidated = { onNavigate(Login) }, + ) + } + entry { + LoginScreen( + onLoginSuccess = onAuthComplete, + onNavigateToRegister = { onNavigate(Register) }, + onNavigateToForgotPassword = { onNavigate(ForgotPassword) }, + onNavigateToTwoFactor = { tempToken -> + onNavigate(TwoFactor(tempToken = tempToken)) + }, + ) + } + entry { + RegisterScreen( + onRegistered = onBack, + onNavigateToLogin = onBack, + ) + } + entry { + ForgotPasswordScreen( + onBack = onBack, + ) + } + entry { key -> + TwoFactorScreen( + onVerified = onAuthComplete, + onBack = onBack, + tempToken = key.tempToken, + ) + } + entry { key -> + VerifyEmailScreen( + onVerified = onBack, + onBack = onBack, + email = key.email, + ) + } + entry { + TermsScreen( + onAccepted = onAuthComplete, + onBack = onBack, + ) + } + entry { key -> + ResetPasswordScreen( + onResetComplete = onBack, + onBack = onBack, + userId = key.userId, + token = key.token, + ) } } val authSerializersModule = SerializersModule { - polymorphic(AuthRoute::class) { + polymorphic(NavKey::class) { subclass(ServerUrl::class, ServerUrl.serializer()) subclass(Login::class, Login.serializer()) subclass(Register::class, Register.serializer()) diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt index 83c41f6..4cc0ee8 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt @@ -35,14 +35,17 @@ import librechat_mobile.feature.auth.generated.resources.Res import librechat_mobile.feature.auth.generated.resources.* import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun ResetPasswordScreen( onResetComplete: () -> Unit, onBack: () -> Unit, + userId: String? = null, + token: String? = null, modifier: Modifier = Modifier, - viewModel: ResetPasswordViewModel = koinViewModel(), + viewModel: ResetPasswordViewModel = koinViewModel { parametersOf(userId, token) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt index 9419d3a..92c6b68 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt @@ -42,14 +42,16 @@ import librechat_mobile.feature.auth.generated.resources.Res import librechat_mobile.feature.auth.generated.resources.* import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun TwoFactorScreen( onVerified: () -> Unit, onBack: () -> Unit, + tempToken: String? = null, modifier: Modifier = Modifier, - viewModel: TwoFactorViewModel = koinViewModel(), + viewModel: TwoFactorViewModel = koinViewModel { parametersOf(tempToken) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt index 280bba6..31af0f3 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt @@ -34,14 +34,16 @@ import librechat_mobile.feature.auth.generated.resources.Res import librechat_mobile.feature.auth.generated.resources.* import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun VerifyEmailScreen( onVerified: () -> Unit, onBack: () -> Unit, + email: String? = null, modifier: Modifier = Modifier, - viewModel: VerifyEmailViewModel = koinViewModel(), + viewModel: VerifyEmailViewModel = koinViewModel { parametersOf(email) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val email = uiState.email diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt index 93c3e3b..650cbcf 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt @@ -23,10 +23,12 @@ data class ResetPasswordUiState( class ResetPasswordViewModel( savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, + initialUserId: String? = null, + initialToken: String? = null, ) : ViewModel() { - private val userId: String = savedStateHandle["userId"] ?: "" - private val token: String = savedStateHandle["token"] ?: "" + private val userId: String = initialUserId ?: "" + private val token: String = initialToken ?: "" private val _uiState = MutableStateFlow(ResetPasswordUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt index 6357807..2b429de 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt @@ -24,9 +24,10 @@ data class TwoFactorUiState( class TwoFactorViewModel( savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, + initialTempToken: String? = null, ) : ViewModel() { - private val tempToken: String = savedStateHandle["tempToken"] ?: "" + private val tempToken: String = initialTempToken ?: "" private val _uiState = MutableStateFlow(TwoFactorUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt index 737f16e..016f001 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt @@ -26,10 +26,11 @@ data class VerifyEmailUiState( class VerifyEmailViewModel( savedStateHandle: SavedStateHandle, private val userRepository: UserRepository, + initialEmail: String? = null, ) : ViewModel() { private val _uiState = MutableStateFlow( - VerifyEmailUiState(email = savedStateHandle["email"] ?: ""), + VerifyEmailUiState(email = initialEmail ?: ""), ) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/chat/CLAUDE.md b/feature/chat/CLAUDE.md index 0df2fe0..647b66c 100644 --- a/feature/chat/CLAUDE.md +++ b/feature/chat/CLAUDE.md @@ -7,10 +7,11 @@ - **Active**: message list + streaming content + input area ## Navigation -- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}") -- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen -- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `chat/{id}` after the first stream completes (data persisted to Room), keeping `new_chat` in the back stack -- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat` +- Sealed interface: `ChatRoute : NavKey` with typed route classes +- Routes: `NewChat` (landing), `Chat(conversationId: String? = null)`, `PromptsLibrary`, `PromptEditor(groupId: String? = null)` (all `@Serializable`) +- Feature entries registered via `EntryProviderScope.chatEntries()` +- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `Chat(id)` after the first stream completes (data persisted to Room), keeping `NewChat` in the back stack +- `navigateToChat()` replaces the current chat entry on the `NavBackStack` when switching between chats so back returns to `NewChat` ## Message Tree - Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID) diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt index 9776696..c93240d 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt @@ -18,9 +18,10 @@ actual val chatPlatformModule: Module = module { ) } - viewModel { + viewModel { params -> ChatViewModel( savedStateHandle = get(), + initialConversationId = params.getOrNull(), agentRepository = get(), chatRepository = get(), messageRepository = get(), diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt index caef207..4852754 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt @@ -97,18 +97,20 @@ import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable actual fun ChatScreen( modifier: Modifier, + conversationId: String?, onConversationStarted: ((String) -> Unit)?, onNavigateToConversation: ((String) -> Unit)?, onOpenDrawer: (() -> Unit)?, onNavigateToPromptsLibrary: (() -> Unit)?, onNavigateBack: (() -> Unit)?, ) { - val viewModel: ChatViewModel = koinViewModel() + val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle() @@ -232,9 +234,9 @@ actual fun ChatScreen( } } - // When a new conversation starts, navigate to chat/{conversationId} immediately - // (at StreamEvent.Created) so the new_chat landing page stays clean in the back - // stack. The new ChatViewModel at chat/{id} will resume the active stream. + // When a new conversation starts, navigate to Chat(conversationId) immediately + // (at StreamEvent.Created) so the NewChat landing page stays clean in the back + // stack. The new ChatViewModel at Chat(id) will resume the active stream. // onPendingNavigationHandled() resets this ViewModel to a fresh landing state. LaunchedEffect(uiState.pendingNavigationConversationId) { val pendingId = uiState.pendingNavigationConversationId diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt index 29c5076..a9b2593 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt @@ -3,13 +3,20 @@ package com.garfiec.librechat.feature.chat.di import com.garfiec.librechat.feature.chat.prompts.PromptEditorViewModel import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val chatModule = module { includes(chatPlatformModule) viewModelOf(::PromptsViewModel) - viewModelOf(::PromptEditorViewModel) + viewModel { params -> + PromptEditorViewModel( + promptRepository = get(), + savedStateHandle = get(), + initialGroupId = params.getOrNull(), + ) + } } expect val chatPlatformModule: Module 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 33050c6..2267513 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 @@ -1,13 +1,7 @@ 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.compose.composable -import androidx.navigation.navigation -import kotlin.reflect.KClass +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen import com.garfiec.librechat.feature.chat.screen.ChatScreen @@ -17,96 +11,56 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass -@Serializable sealed interface ChatRoute +@Serializable sealed interface ChatRoute : NavKey @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.isRoute(Chat::class) - val currentDestId = currentEntry?.destination?.id - 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 - // before the first chat (e.g. new_chat, settings, agents). - popUpTo(currentDestId) { inclusive = true } - } - // From non-chat screens just push onto the backstack normally - launchSingleTop = true - } -} - -fun NavController.navigateToPromptsLibrary() { - navigate(PromptsLibrary) -} - -fun NavController.navigateToPromptEditor(groupId: String? = null) { - navigate(PromptEditor(groupId = groupId)) -} - -fun NavGraphBuilder.chatGraph( - navController: NavController, +fun EntryProviderScope.chatEntries( + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, + onNavigateToChat: (String) -> Unit, onOpenDrawer: (() -> Unit)? = null, ) { - navigation(startDestination = NewChat::class) { - composable( - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { null }, - popExitTransition = { null }, - ) { - NewChatScreen( - onConversationStarted = { conversationId -> - navController.navigateToChat(conversationId) - }, - onOpenDrawer = onOpenDrawer, - onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, - ) - } - composable( - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { null }, - popExitTransition = { null }, - ) { _ -> - ChatScreen( - onOpenDrawer = onOpenDrawer, - onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) }, - ) - } - composable { - PromptsLibraryScreen( - onNavigateBack = { navController.popBackStack() }, - onUseInChat = { promptText -> - navController.popBackStack() - }, - onNavigateToEditor = { groupId -> - navController.navigateToPromptEditor(groupId) - }, - ) - } - composable { - PromptEditorScreen( - onBack = { navController.popBackStack() }, - ) - } + entry { + NewChatScreen( + onConversationStarted = { conversationId -> + onNavigateToChat(conversationId) + }, + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) }, + ) + } + entry { key -> + ChatScreen( + conversationId = key.conversationId, + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) }, + onNavigateBack = onBack, + onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) }, + ) + } + entry { + PromptsLibraryScreen( + onNavigateBack = onBack, + onUseInChat = { _ -> onBack() }, + onNavigateToEditor = { groupId -> + onNavigate(PromptEditor(groupId = groupId)) + }, + ) + } + entry { key -> + PromptEditorScreen( + onBack = onBack, + groupId = key.groupId, + ) } } val chatSerializersModule = SerializersModule { - polymorphic(ChatRoute::class) { + polymorphic(NavKey::class) { subclass(NewChat::class, NewChat.serializer()) subclass(Chat::class, Chat.serializer()) subclass(PromptsLibrary::class, PromptsLibrary.serializer()) diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt index 9bb7e4b..d9cae53 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt @@ -38,13 +38,15 @@ import com.garfiec.librechat.core.ui.components.LoadingIndicator import librechat_mobile.feature.chat.generated.resources.Res import librechat_mobile.feature.chat.generated.resources.* import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun PromptEditorScreen( onBack: () -> Unit, modifier: Modifier = Modifier, - viewModel: PromptEditorViewModel = koinViewModel(), + groupId: String? = null, + viewModel: PromptEditorViewModel = koinViewModel { parametersOf(groupId) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt index e0bb1e4..84e9c35 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt @@ -40,13 +40,14 @@ data class PromptEditorUiState( class PromptEditorViewModel( private val promptRepository: PromptRepository, savedStateHandle: SavedStateHandle, + initialGroupId: String? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(PromptEditorUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - val groupId = savedStateHandle.get("groupId") + val groupId = initialGroupId if (groupId != null) { loadGroup(groupId) } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt index e489428..f6939b1 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier @Composable expect fun ChatScreen( modifier: Modifier = Modifier, + conversationId: String? = null, onConversationStarted: ((String) -> Unit)? = null, onNavigateToConversation: ((String) -> Unit)? = null, onOpenDrawer: (() -> Unit)? = null, diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt index 6583aa4..5a87faf 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt @@ -125,7 +125,7 @@ data class ChatUiState( val showDeleteConfirmation: Boolean = false, val duplicatedConversationId: String? = null, /** Set at StreamEvent.Created when a new conversation's conversationId becomes available. - * The UI navigates to chat/{id} and then clears this via [ChatViewModel.onPendingNavigationHandled], + * The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled], * which also resets this ViewModel to a clean landing state. */ val pendingNavigationConversationId: String? = null, // Model comparison state diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt index 13e5b1e..979818c 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt @@ -58,6 +58,7 @@ import kotlin.uuid.Uuid class ChatViewModel( savedStateHandle: SavedStateHandle, + initialConversationId: String? = null, private val agentRepository: AgentRepository, private val chatRepository: ChatRepository, private val messageRepository: MessageRepository, @@ -163,7 +164,7 @@ class ChatViewModel( private var titleGenerationRequested = false init { - val conversationId = savedStateHandle.get("conversationId") + val conversationId = initialConversationId isNewConversation = conversationId == null if (conversationId != null) { _uiState.value = _uiState.value.copy( @@ -174,7 +175,7 @@ class ChatViewModel( loadConversationModel(conversationId) restoreDraft(conversationId) // Check if there's an active stream for this conversation (e.g. when - // navigating here from new_chat immediately after sending). If so, + // navigating here from NewChat immediately after sending). If so, // resume it so the user sees streaming content on this screen. resumeActiveStreamIfNeeded(conversationId) } else { @@ -255,7 +256,7 @@ class ChatViewModel( } // Restore MCP server and tool selections from DataStore so they - // survive the new_chat -> chat/{id} navigation re-creation. + // survive the NewChat -> Chat(id) navigation re-creation. viewModelScope.launch { val mcpServers = settingsDataStore.selectedMcpServers.first() val tools = settingsDataStore.enabledTools.first() diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt index 7c20aff..fa18e03 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt @@ -16,9 +16,10 @@ actual val chatPlatformModule: Module = module { ) } - viewModel { + viewModel { params -> ChatViewModel( savedStateHandle = get(), + initialConversationId = params.getOrNull(), agentRepository = get(), chatRepository = get(), messageRepository = get(), diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt index d29912f..ff660ea 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt @@ -81,18 +81,20 @@ import librechat_mobile.feature.chat.generated.resources.Res import librechat_mobile.feature.chat.generated.resources.* import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable actual fun ChatScreen( modifier: Modifier, + conversationId: String?, onConversationStarted: ((String) -> Unit)?, onNavigateToConversation: ((String) -> Unit)?, onOpenDrawer: (() -> Unit)?, onNavigateToPromptsLibrary: (() -> Unit)?, onNavigateBack: (() -> Unit)?, ) { - val viewModel: ChatViewModel = koinViewModel() + val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle() diff --git a/feature/conversations/CLAUDE.md b/feature/conversations/CLAUDE.md index 48107f0..ae7a833 100644 --- a/feature/conversations/CLAUDE.md +++ b/feature/conversations/CLAUDE.md @@ -1,7 +1,7 @@ # feature:conversations ## Screen -`ConversationListScreen` -- the drawer content showing all conversations. Single route: `CONVERSATIONS_ROUTE`. +`ConversationListScreen` -- the drawer content showing all conversations. Sealed interface: `ConversationsRoute : NavKey` with routes `Conversations` and `ArchivedConversations` (both `@Serializable` data objects). ## Pagination - Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)` 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 d09dbd3..d02b101 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 @@ -1,8 +1,7 @@ package com.garfiec.librechat.feature.conversations.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen import kotlinx.serialization.Serializable @@ -10,35 +9,31 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass -@Serializable sealed interface ConversationsRoute +@Serializable sealed interface ConversationsRoute : NavKey @Serializable data object Conversations : ConversationsRoute @Serializable data object ArchivedConversations : ConversationsRoute -fun NavGraphBuilder.conversationsGraph( +fun EntryProviderScope.conversationsEntries( onConversationClick: (String) -> Unit, onNavigateToArchived: () -> Unit = {}, - onNavigateBackFromArchived: () -> Unit = {}, + onBack: () -> Unit = {}, ) { - composable { - ScreenTransitionWrapper(transition) { - ConversationListScreen( - onConversationClick = onConversationClick, - onNavigateToArchived = onNavigateToArchived, - ) - } + entry { + ConversationListScreen( + onConversationClick = onConversationClick, + onNavigateToArchived = onNavigateToArchived, + ) } - composable { - ScreenTransitionWrapper(transition) { - ArchivedConversationsScreen( - onNavigateBack = onNavigateBackFromArchived, - ) - } + entry { + ArchivedConversationsScreen( + onNavigateBack = onBack, + ) } } val conversationsSerializersModule = SerializersModule { - polymorphic(ConversationsRoute::class) { + polymorphic(NavKey::class) { subclass(Conversations::class, Conversations.serializer()) subclass(ArchivedConversations::class, ArchivedConversations.serializer()) } diff --git a/feature/files/CLAUDE.md b/feature/files/CLAUDE.md index 07e4113..cfcc2dc 100644 --- a/feature/files/CLAUDE.md +++ b/feature/files/CLAUDE.md @@ -1,7 +1,7 @@ # feature:files ## Screen -`FilesScreen` -- single route `FILES_ROUTE`. Lists uploaded files with upload and delete actions. +`FilesScreen` -- sealed interface `FilesRoute : NavKey` with single route `Files` (`@Serializable` data object). Lists uploaded files with upload and delete actions. ## ViewModel `FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver). 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 82fb823..d3154a0 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 @@ -1,30 +1,27 @@ package com.garfiec.librechat.feature.files.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.files.screen.FilesScreen import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass -@Serializable sealed interface FilesRoute +@Serializable sealed interface FilesRoute : NavKey @Serializable data object Files : FilesRoute -fun NavGraphBuilder.filesGraph( +fun EntryProviderScope.filesEntries( onBack: (() -> Unit)? = null, ) { - composable { - ScreenTransitionWrapper(transition) { - FilesScreen(onBack = onBack) - } + entry { + FilesScreen(onBack = onBack) } } val filesSerializersModule = SerializersModule { - polymorphic(FilesRoute::class) { + polymorphic(NavKey::class) { subclass(Files::class, Files.serializer()) } } diff --git a/feature/settings/CLAUDE.md b/feature/settings/CLAUDE.md index 1d5ab38..75e2978 100644 --- a/feature/settings/CLAUDE.md +++ b/feature/settings/CLAUDE.md @@ -1,7 +1,7 @@ # feature:settings ## Screen -`SettingsScreen` -- single route `SETTINGS_ROUTE`. Receives `onLogout` and `onNavigateBack` callbacks. +`SettingsScreen` -- sealed interface `SettingsRoute : NavKey` with primary route `SettingsTabbed` (`@Serializable` data object). Receives `onLogout` and `onNavigateBack` callbacks. ## Sections - **Account**: displays user profile from `UserApi.getUser()` @@ -46,14 +46,14 @@ - `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`) - `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`) - `CommandsConfigScreen` — enable/disable slash commands -- Routes: `MEMORIES_ROUTE`, `MCP_SERVERS_ROUTE`, `PRESET_MANAGER_ROUTE`, `COMMANDS_CONFIG_ROUTE` +- Routes: `Memories`, `McpServers`, `PresetManager` (all `@Serializable` data objects extending `SettingsRoute`) - **Gotcha**: Memories and MCP navigation routes defined in their own files (`MemoriesNavigation.kt`, `McpNavigation.kt`) but wired through `SettingsNavigation.kt` - **Gotcha**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations - **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field) ### API Keys - `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`) -- Route: `API_KEYS_ROUTE` ("settings/api_keys"), accessible from Settings → Security section +- Route: `ApiKeys` (`@Serializable` data object extending `SettingsRoute`), accessible from Settings → Security section - `ApiKeyCreateDialog` is two-phase: name input → show created key value with copy button - **Gotcha**: The key value is only available in the creation response — it cannot be retrieved again from the server. The dialog must show it immediately after creation. 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 68c384c..2471dfd 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 @@ -1,18 +1,18 @@ package com.garfiec.librechat.feature.settings.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.settings.screen.McpServersScreen import kotlinx.serialization.Serializable @Serializable data object McpServers : SettingsRoute -fun NavGraphBuilder.mcpServersScreen( - onNavigateBack: () -> Unit, +fun EntryProviderScope.mcpServersEntry( + onBack: () -> Unit, ) { - composable { + entry { McpServersScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, ) } } 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 d96d8df..d7b7939 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 @@ -1,18 +1,18 @@ package com.garfiec.librechat.feature.settings.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.settings.screen.MemoriesScreen import kotlinx.serialization.Serializable @Serializable data object Memories : SettingsRoute -fun NavGraphBuilder.memoriesScreen( - onNavigateBack: () -> Unit, +fun EntryProviderScope.memoriesEntry( + onBack: () -> Unit, ) { - composable { + entry { MemoriesScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, ) } } 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 f519f63..3db073d 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 @@ -2,8 +2,8 @@ package com.garfiec.librechat.feature.settings.navigation import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen @@ -19,7 +19,7 @@ import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass import org.koin.compose.viewmodel.koinViewModel -@Serializable sealed interface SettingsRoute +@Serializable sealed interface SettingsRoute : NavKey @Serializable data object SettingsTabbed : SettingsRoute @Serializable data object SettingsGeneral : SettingsRoute @@ -30,54 +30,48 @@ import org.koin.compose.viewmodel.koinViewModel @Serializable data object PresetManager : SettingsRoute @Serializable data object ApiKeys : SettingsRoute -fun NavGraphBuilder.settingsGraph( +fun EntryProviderScope.settingsEntries( + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, onLogout: () -> Unit, - onNavigateBack: () -> Unit, onNavigateToArchived: () -> Unit = {}, - onNavigateToSharedLinks: () -> Unit = {}, - onNavigateBackFromSharedLinks: () -> Unit = {}, - onNavigateToPresets: () -> Unit = {}, - onNavigateBackFromPresets: () -> Unit = {}, - onNavigateToApiKeys: () -> Unit = {}, - onNavigateBackFromApiKeys: () -> Unit = {}, ) { - composable { + entry { TabbedSettingsScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, onLogout = onLogout, onNavigateToArchived = onNavigateToArchived, - onNavigateToSharedLinks = onNavigateToSharedLinks, - onNavigateToPresets = onNavigateToPresets, - onNavigateToApiKeys = onNavigateToApiKeys, + onNavigateToSharedLinks = { onNavigate(SharedLinks) }, + onNavigateToPresets = { onNavigate(PresetManager) }, + onNavigateToApiKeys = { onNavigate(ApiKeys) }, ) } - - composable { + entry { GeneralSettingsScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, ) } - composable { + entry { ChatSettingsScreen( - onNavigateBack = onNavigateBack, - onNavigateToPresets = onNavigateToPresets, + onNavigateBack = onBack, + onNavigateToPresets = { onNavigate(PresetManager) }, ) } - composable { + entry { AccountSettingsScreen( onLogout = onLogout, - onNavigateBack = onNavigateBack, - onNavigateToApiKeys = onNavigateToApiKeys, + onNavigateBack = onBack, + onNavigateToApiKeys = { onNavigate(ApiKeys) }, ) } - composable { + entry { DataSettingsScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, onNavigateToArchived = onNavigateToArchived, - onNavigateToSharedLinks = onNavigateToSharedLinks, + onNavigateToSharedLinks = { onNavigate(SharedLinks) }, ) } - composable { + entry { val viewModel: SettingsViewModel = koinViewModel() val uiState = viewModel.uiState.collectAsStateWithLifecycle() @@ -93,23 +87,23 @@ fun NavGraphBuilder.settingsGraph( onLoadMore = viewModel::loadMoreSharedLinks, onToggleVisibility = viewModel::toggleSharedLinkVisibility, onDelete = viewModel::deleteSharedLink, - onNavigateBack = onNavigateBackFromSharedLinks, + onNavigateBack = onBack, ) } - composable { + entry { PresetManagerScreen( - onNavigateBack = onNavigateBackFromPresets, + onNavigateBack = onBack, ) } - composable { + entry { ApiKeysScreen( - onNavigateBack = onNavigateBackFromApiKeys, + onNavigateBack = onBack, ) } } val settingsSerializersModule = SerializersModule { - polymorphic(SettingsRoute::class) { + polymorphic(NavKey::class) { subclass(SettingsTabbed::class, SettingsTabbed.serializer()) subclass(SettingsGeneral::class, SettingsGeneral.serializer()) subclass(SettingsChat::class, SettingsChat.serializer()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 132e63a..eb7f706 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,10 +7,10 @@ compose-bom = "2025.05.00" compose-multiplatform = "1.11.0-beta01" material3 = "1.3.1" activity-compose = "1.10.1" -navigation-compose = "2.9.2" lifecycle = "2.9.0" lifecycle-kmp = "2.9.0" -navigation-kmp = "2.9.2" +navigation3-kmp = "1.0.0-alpha06" +lifecycle-viewmodel-nav3-kmp = "2.10.0-alpha05" # DI koin = "4.2.0" @@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation # Activity / Navigation activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } -navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } - # Lifecycle lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" } lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" } -navigation-compose-kmp = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation-kmp" } +navigation3-ui-kmp = { group = "org.jetbrains.androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3-kmp" } +lifecycle-viewmodel-navigation3-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle-viewmodel-nav3-kmp" } # Koin koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 5228259..55428df 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -42,9 +42,10 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.coroutines.core) implementation(libs.kermit) - implementation(libs.navigation.compose.kmp) + implementation(libs.navigation3.ui.kmp) implementation(libs.lifecycle.runtime.compose.kmp) implementation(libs.lifecycle.viewmodel.compose.kmp) + implementation(libs.lifecycle.viewmodel.navigation3.kmp) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel.navigation) 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 6ae039b..7415953 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 @@ -1,6 +1,5 @@ package com.garfiec.librechat.shared.navigation -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -8,7 +7,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -26,43 +27,36 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.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 androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import com.garfiec.librechat.core.ui.components.BannerDisplay -import com.garfiec.librechat.feature.agents.navigation.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.agents.navigation.agentsEntries import com.garfiec.librechat.feature.auth.navigation.AuthRoute import com.garfiec.librechat.feature.auth.navigation.ServerUrl -import com.garfiec.librechat.feature.auth.navigation.authGraph +import com.garfiec.librechat.feature.auth.navigation.authEntries 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.chat.navigation.chatEntries import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations -import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph +import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries import com.garfiec.librechat.feature.files.navigation.Files -import com.garfiec.librechat.feature.files.navigation.filesGraph -import com.garfiec.librechat.feature.settings.navigation.ApiKeys -import com.garfiec.librechat.feature.settings.navigation.PresetManager +import com.garfiec.librechat.feature.files.navigation.filesEntries import com.garfiec.librechat.feature.settings.navigation.SettingsAccount import com.garfiec.librechat.feature.settings.navigation.SettingsChat import com.garfiec.librechat.feature.settings.navigation.SettingsData import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral import com.garfiec.librechat.feature.settings.navigation.SettingsRoute import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed -import com.garfiec.librechat.feature.settings.navigation.SharedLinks -import com.garfiec.librechat.feature.settings.navigation.settingsGraph +import com.garfiec.librechat.feature.settings.navigation.memoriesEntry +import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry +import com.garfiec.librechat.feature.settings.navigation.settingsEntries import kotlinx.coroutines.launch import librechat_mobile.shared.generated.resources.Res import librechat_mobile.shared.generated.resources.dismiss @@ -72,12 +66,6 @@ import librechat_mobile.shared.generated.resources.version_mismatch_title import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel -/** 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 @@ -99,41 +87,35 @@ fun LibreChatNavHost( modifier: Modifier = Modifier, navHostViewModel: NavHostViewModel = koinViewModel(), ) { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() - val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) || - currentDestination?.parent.isRoute(AuthRoute::class) + // Stable start key — auth redirect handled via LaunchedEffect below. + val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat) + val navigator = Navigator(backStack) + + // Redirect to auth if not logged in (on first composition only). + LaunchedEffect(Unit) { + if (!navHostViewModel.isLoggedIn.value) { + navigator.navigateToAuth() + } + } // Track active conversation from nav back stack - LaunchedEffect(navBackStackEntry) { - val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) { - navBackStackEntry?.toRoute()?.conversationId - } else { - null - } + LaunchedEffect(navigator.currentRoute) { + val conversationId = (navigator.currentRoute as? Chat)?.conversationId navHostViewModel.setActiveConversation(conversationId) } // Handle session expiry LaunchedEffect(Unit) { navHostViewModel.sessionExpired.collect { - navController.navigate(ServerUrl) { - popUpTo(0) { inclusive = true } - } + navigator.navigateToAuth() } } - val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class - PhoneLayout( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, - isInAuthFlow = isInAuthFlow, modifier = modifier, ) @@ -185,10 +167,8 @@ private fun VersionMismatchDialog( @Composable private fun PhoneLayout( - navController: androidx.navigation.NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: Any, - isInAuthFlow: Boolean, modifier: Modifier = Modifier, ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -205,54 +185,42 @@ private fun PhoneLayout( ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = !isInAuthFlow, + gesturesEnabled = !navigator.isInAuthFlow, drawerContent = { ModalDrawerSheet { SidebarScaffold( viewModel = navHostViewModel, onNewChat = { scope.launch { drawerState.close() } - val currentRoute = navController.currentBackStackEntry?.destination - if (!currentRoute.isRoute(NewChat::class)) { - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } + if (navigator.currentRoute !is NewChat) { + navigator.navigateToTopLevel(NewChat) } }, onConversationClick = { conversationId -> scope.launch { drawerState.close() } - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) }, onSettingsClick = { scope.launch { drawerState.close() } - navController.navigate(SettingsTabbed) { - launchSingleTop = true - } + navigator.navigate(SettingsTabbed) }, onSettingsCategorySelected = { category -> - navController.navigate(category.toRoute()) { - launchSingleTop = true - } + navigator.navigate(category.toRoute()) }, onAgentsClick = { scope.launch { drawerState.close() } - navController.navigate(AgentMarketplace) { - launchSingleTop = true - } + navigator.navigate(AgentMarketplace) }, onFilesClick = { scope.launch { drawerState.close() } - navController.navigate(Files) { - launchSingleTop = true - } + navigator.navigate(Files) }, ) } }, ) { Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - if (!isInAuthFlow && banners.isNotEmpty()) { + if (!navigator.isInAuthFlow && banners.isNotEmpty()) { BannerDisplay( banners = banners, dismissedIds = dismissedBannerIds, @@ -260,124 +228,88 @@ private fun PhoneLayout( modifier = Modifier.padding(top = 4.dp), ) } - NavHost( - navController = navController, - startDestination = startDestination, - modifier = Modifier.fillMaxSize(), - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(300), + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(150)) + }, + popTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + predictivePopTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + authEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navigator.navigateToChat() + }, + ) + chatEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onNavigateToChat = { navigator.navigateToChat(it) }, + onOpenDrawer = { + scope.launch { drawerState.open() } + }, + ) + conversationsEntries( + onConversationClick = { navigator.navigateToChat(it) }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + onBack = { navigator.goBack() }, + ) + agentsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onStartChat = { _ -> + navigator.navigateToTopLevel(NewChat) + }, + ) + filesEntries( + onBack = { navigator.goBack() }, + ) + settingsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onLogout = { + navHostViewModel.logout() + navigator.navigateToAuth() + }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + ) + memoriesEntry( + onBack = { navigator.goBack() }, + ) + mcpServersEntry( + onBack = { navigator.goBack() }, ) }, - exitTransition = { - fadeOut(animationSpec = tween(150)) - }, - popEnterTransition = { - fadeIn( - initialAlpha = 0.5f, - animationSpec = tween(300, easing = LinearEasing), - ) + scaleIn( - initialScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - popExitTransition = { - slideOut( - targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - ) { - authGraph( - navController = navController, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navController.navigate(NewChat) { - popUpTo { inclusive = true } - } - }, - ) - chatGraph( - navController = navController, - onOpenDrawer = { - scope.launch { drawerState.open() } - }, - ) - conversationsGraph( - onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) - }, - onNavigateToArchived = { - navController.navigate(ArchivedConversations) - }, - onNavigateBackFromArchived = { - navController.popBackStack() - }, - ) - agentsGraph( - onAgentClick = { agentId -> - navController.navigate(AgentDetail(agentId = agentId)) - }, - onBack = { navController.popBackStack() }, - onStartChat = { agentId -> - navController.navigate(NewChat) { - popUpTo { inclusive = false } - launchSingleTop = true - } - }, - onCreateAgent = { - navController.navigate(AgentEditorCreate) - }, - onEditAgent = { agentId -> - navController.navigate(AgentEditorEdit(agentId = agentId)) - }, - ) - filesGraph( - onBack = { navController.popBackStack() }, - ) - settingsGraph( - onLogout = { - navHostViewModel.logout() - navController.navigate(ServerUrl) { - popUpTo(0) { inclusive = true } - } - }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToArchived = { - navController.navigate(ArchivedConversations) { - launchSingleTop = true - } - }, - onNavigateToSharedLinks = { - navController.navigate(SharedLinks) { - launchSingleTop = true - } - }, - onNavigateBackFromSharedLinks = { - navController.popBackStack() - }, - onNavigateToPresets = { - navController.navigate(PresetManager) { - launchSingleTop = true - } - }, - onNavigateBackFromPresets = { - navController.popBackStack() - }, - onNavigateToApiKeys = { - navController.navigate(ApiKeys) { - launchSingleTop = true - } - }, - onNavigateBackFromApiKeys = { - navController.popBackStack() - }, - ) - } + ) } } } 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 index 4839769..02143ea 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt @@ -1,5 +1,6 @@ package com.garfiec.librechat.shared.navigation +import androidx.savedstate.serialization.SavedStateConfiguration import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule import com.garfiec.librechat.feature.auth.navigation.authSerializersModule import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule @@ -12,7 +13,7 @@ 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. + * Used by Nav 3's SavedStateConfiguration for type-safe state saving. */ val navigationSerializersModule: SerializersModule = authSerializersModule + @@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule = agentsSerializersModule + filesSerializersModule + settingsSerializersModule + +/** + * Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types. + * Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms. + */ +val navigationSavedStateConfig = SavedStateConfiguration { + serializersModule = navigationSerializersModule +} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/Navigator.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/Navigator.kt new file mode 100644 index 0000000..17be1aa --- /dev/null +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/Navigator.kt @@ -0,0 +1,60 @@ +package com.garfiec.librechat.shared.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.feature.auth.navigation.AuthRoute +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat + +/** + * Encapsulates all back stack mutations for Nav 3 navigation. + * Follows UDF: UI events → Navigator → back stack state → NavDisplay recomposition. + */ +class Navigator(val backStack: NavBackStack) { + + val currentRoute: NavKey? get() = backStack.lastOrNull() + + val isInAuthFlow: Boolean get() = currentRoute is AuthRoute + + /** Navigate forward to a route. */ + fun navigate(route: NavKey) { + backStack.add(route) + } + + /** Pop the top entry. Safe on empty stacks. */ + fun goBack() { + backStack.removeLastOrNull() + } + + /** Navigate to a chat, replacing any current chat on the stack. */ + fun navigateToChat(conversationId: String) { + if (backStack.lastOrNull() is Chat) { + backStack.removeLastOrNull() + } + backStack.add(Chat(conversationId)) + } + + /** Navigate to a top-level destination, popping to root first. */ + fun navigateToTopLevel(route: NavKey) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + if (backStack.lastOrNull()?.let { it::class } != route::class) { + backStack.removeLastOrNull() + backStack.add(route) + } + } + + /** Clear back stack and navigate to auth (session expiry / logout). */ + fun navigateToAuth() { + backStack.clear() + backStack.add(ServerUrl) + } + + /** Clear back stack and navigate to chat (auth complete). */ + fun navigateToChat() { + backStack.clear() + backStack.add(NewChat) + } +} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/TopLevelDestination.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/TopLevelDestination.kt deleted file mode 100644 index e25dafb..0000000 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/TopLevelDestination.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.garfiec.librechat.shared.navigation - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.ui.graphics.vector.ImageVector - -/** - * Destinations accessible from the navigation drawer footer. - * The primary navigation is sidebar-first (drawer with conversation list), - * matching the web frontend pattern. - */ -enum class TopLevelDestination( - val route: String, - val icon: ImageVector, - val label: String, -) { - CHAT("chat_graph", Icons.AutoMirrored.Filled.Chat, "Chat"), - CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"), - AGENTS("agents", Icons.Default.SmartToy, "Agents"), - FILES("files", Icons.Default.Folder, "Files"), -} From d8effb7ea3b954149d645281ebbd4d0d2fa76693 Mon Sep 17 00:00:00 2001 From: Garfie Date: Sat, 4 Apr 2026 22:27:24 -0500 Subject: [PATCH 3/3] chore: remove Nav 3 research document --- NAV3_RESEARCH.md | 841 ----------------------------------------------- 1 file changed, 841 deletions(-) delete mode 100644 NAV3_RESEARCH.md diff --git a/NAV3_RESEARCH.md b/NAV3_RESEARCH.md deleted file mode 100644 index 4a3a7d8..0000000 --- a/NAV3_RESEARCH.md +++ /dev/null @@ -1,841 +0,0 @@ -# Navigation Compose 3: Research Report - -> Compiled 2026-04-03. Sources: Android Developer docs, JetBrains Kotlin docs, nav3-recipes repo, Android Developers Blog, community blog posts, and release notes. - ---- - -## Table of Contents - -1. [Overview](#1-overview) -2. [Core Concepts & API](#2-core-concepts--api) -3. [Best Practices](#3-best-practices) -4. [Migration Guide (Nav 2 → Nav 3)](#4-migration-guide-nav-2--nav-3) -5. [Gotchas, Footguns & Complications](#5-gotchas-footguns--complications) -6. [KMP / Compose Multiplatform Considerations](#6-kmp--compose-multiplatform-considerations) -7. [Appendix: Version & Dependency Reference](#7-appendix-version--dependency-reference) - ---- - -## 1. Overview - -### What is Navigation 3? - -Navigation 3 (Nav3) is a **ground-up rewrite** of Android's Jetpack Navigation, designed specifically for Jetpack Compose. Released as stable **1.0.0 on November 19, 2025**, it replaces the controller-based paradigm of Nav 2 with a **state-based, declarative approach** where the developer owns the back stack directly. - -- **Stable release**: 1.0.1 (Feb 11, 2026) -- **Latest**: 1.1.0-rc01 (Mar 25, 2026) -- **Min SDK**: 23 -- **Compile SDK**: 36+ - -### How It Differs from Nav 2 - -| Aspect | Nav 2 | Nav 3 | -|--------|-------|-------| -| **State ownership** | Library-owned (`NavController`) | Developer-owned (`SnapshotStateList`) | -| **Back stack model** | Hidden internal structure | Transparent mutable list | -| **Navigation API** | `navController.navigate()` | `backStack.add(key)` | -| **Host component** | `NavHost` | `NavDisplay` | -| **Route definition** | `composable { }` in `NavHost` | `entry { }` in `entryProvider` | -| **Layout flexibility** | Single pane only | Multi-pane via Scenes API | -| **Nested graphs** | `navigation { }` | Not needed — flat entry list | -| **Design philosophy** | Opinionated framework | Flexible building blocks | -| **Compose alignment** | Bolted-on Compose support | Compose-first from scratch | - -### Founding Principles - -1. **You Own the Back Stack** — Navigation state is a `SnapshotStateList` you control directly. Push = `add()`, pop = `removeLastOrNull()`. -2. **Get Out of Your Way** — The library provides open, extensible building blocks rather than a black box. -3. **Pick Your Building Blocks** — Composable components you combine for your specific needs, with recipes for complex scenarios. - -### Data Flow - -``` -User Action → modify backStack (add/remove) → NavDisplay observes change → entryProvider resolves key → NavEntry.content rendered -``` - -This follows **Unidirectional Data Flow (UDF)**, fully aligned with Compose's declarative model. - ---- - -## 2. Core Concepts & API - -### 2.1 Keys (Routes) - -Routes in Nav 3 are simple Kotlin data types that implement `NavKey`: - -```kotlin -@Serializable -data object Home : NavKey - -@Serializable -data class ProductDetail(val id: String) : NavKey - -@Serializable -data object Settings : NavKey -``` - -Keys must be: -- `@Serializable` (for state persistence) -- Implement `NavKey` (marker interface for `rememberNavBackStack`) - -### 2.2 Back Stack - -The back stack is a `SnapshotStateList` — you own it completely: - -```kotlin -// Simple (no persistence across process death) -val backStack = remember { mutableStateListOf(Home) } - -// Persistent (survives config changes + process death) -val backStack = rememberNavBackStack(Home) -``` - -Navigation is just list manipulation: -```kotlin -// Navigate forward -backStack.add(ProductDetail(id = "123")) - -// Navigate back -backStack.removeLastOrNull() -``` - -### 2.3 NavEntry - -`NavEntry` wraps a composable function with its key and optional metadata: - -```kotlin -NavEntry( - key = ProductDetail("123"), - metadata = mapOf("type" to "detail") -) { - ProductDetailScreen() -} -``` - -### 2.4 Entry Provider - -Resolves keys to `NavEntry` objects. Two approaches: - -**Lambda function:** -```kotlin -entryProvider = { key -> - when (key) { - is Home -> NavEntry(key) { HomeScreen() } - is ProductDetail -> NavEntry(key) { ProductDetailScreen(key.id) } - else -> NavEntry(Unit) { Text("Unknown") } - } -} -``` - -**DSL (recommended):** -```kotlin -entryProvider = entryProvider { - entry { HomeScreen() } - entry { key -> ProductDetailScreen(key.id) } -} -``` - -### 2.5 NavDisplay - -The primary UI component that observes the back stack and renders content: - -```kotlin -NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() - ), - sceneStrategies = listOf(DialogSceneStrategy()), - entryProvider = entryProvider { - entry { HomeScreen() } - entry { key -> ProductDetailScreen(key.id) } - } -) -``` - -### 2.6 NavEntry Decorators - -Decorators wrap each `NavEntry` with additional logic (state holders, ViewModel stores, logging): - -| Decorator | Purpose | -|-----------|---------| -| `rememberSaveableStateHolderNavEntryDecorator()` | Persists `rememberSaveable` state across config changes and process death. **Should be first in the list.** | -| `rememberViewModelStoreNavEntryDecorator()` | Scopes `ViewModel`s to individual `NavEntry` lifecycle. Requires `androidx.lifecycle:lifecycle-viewmodel-navigation3` dependency. | -| `rememberSharedViewModelStoreNavEntryDecorator()` | Enables ViewModel sharing between parent/child entries. | - -Custom decorator example: -```kotlin -class LoggingDecorator : NavEntryDecorator( - decorate = { entry -> - Log.d("Nav", "Entered ${entry.contentKey}") - entry.Content() - }, - onPop = { key -> Log.d("Nav", "Popped $key") } -) -``` - -### 2.7 Scenes & SceneStrategy - -**Scenes** render one or more `NavEntry` instances. **SceneStrategies** determine which Scene to use based on the current entries. - -**Built-in strategies:** -- `SinglePaneSceneStrategy` — Default, shows topmost entry -- `DialogSceneStrategy` — Renders entries with dialog metadata as overlays -- `ListDetailSceneStrategy` (from `material3-adaptive-navigation3`) — Adaptive list-detail layout - -**Custom strategy example (list-detail):** -```kotlin -class ListDetailSceneStrategy( - val windowSizeClass: WindowSizeClass -) : SceneStrategy { - override fun SceneStrategyScope.calculateScene( - entries: List> - ): Scene? { - if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) - return null - val detailEntry = entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null - val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null - return ListDetailScene( - key = listEntry.contentKey, - previousEntries = entries.dropLast(1), - listEntry = listEntry, - detailEntry = detailEntry - ) - } -} -``` - -### 2.8 Metadata - -Arbitrary `Map` attached to `NavEntry` or `Scene` for communication between components: - -```kotlin -// Type-safe metadata DSL (1.1.0-beta01+) -entry( - metadata = metadata { - put(NavDisplay.TransitionKey) { fadeIn() togetherWith fadeOut() } - put(ListDetailSceneStrategy.ListKey, true) - } -) { HomeScreen() } -``` - -### 2.9 Animations - -NavDisplay supports built-in transition animations: - -```kotlin -NavDisplay( - backStack = backStack, - // Global defaults - transitionSpec = { - slideInHorizontally(initialOffsetX = { it }) togetherWith - slideOutHorizontally(targetOffsetX = { -it }) - }, - popTransitionSpec = { - slideInHorizontally(initialOffsetX = { -it }) togetherWith - slideOutHorizontally(targetOffsetX = { it }) - }, - predictivePopTransitionSpec = { - slideInHorizontally(initialOffsetX = { -it }) togetherWith - slideOutHorizontally(targetOffsetX = { it }) - }, - entryProvider = entryProvider { - // Per-entry override via metadata - entry( - metadata = metadata { - put(NavDisplay.TransitionKey) { - slideInVertically(initialOffsetY = { it }) togetherWith - ExitTransition.KeepUntilTransitionsFinished - } - } - ) { ScreenC() } - } -) -``` - -Shared element transitions: -```kotlin -SharedTransitionLayout { - NavDisplay( - sharedTransitionScope = this, - // ... - ) -} -``` - -### 2.10 Deep Linking - -Nav 3 does **not** have built-in deep link support like Nav 2. Instead, you parse intents manually: - -1. Define `DeepLinkPattern` objects mapping URIs to `NavKey` classes -2. Parse incoming `Intent.data` into a `DeepLinkRequest` -3. Match against patterns to produce a `NavKey` -4. Build a synthetic back stack for proper Up navigation - -```kotlin -override fun onCreate(savedInstanceState: Bundle?) { - val uri: Uri? = intent.data - val key: NavKey = uri?.let { parseDeepLink(it) } ?: HomeKey - setContent { - val backStack = rememberNavBackStack(key) - NavDisplay(backStack = backStack, ...) - } -} -``` - -Recipes available: [Basic deep links](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplinks), [Advanced deep links](https://github.com/android/nav3-recipes/tree/main/advanceddeeplinkapp). - ---- - -## 3. Best Practices - -### 3.1 Architecture Pattern - -**Recommended: Navigator + NavigationState pattern** (from official migration guide): - -```kotlin -// NavigationState — holds all navigation state -class NavigationState( - val startRoute: NavKey, - topLevelRoute: MutableState, - val backStacks: Map> -) { - var topLevelRoute: NavKey by topLevelRoute -} - -// Navigator — handles navigation events -class Navigator(val state: NavigationState) { - fun navigate(route: NavKey) { - if (route in state.backStacks.keys) { - state.topLevelRoute = route - } else { - state.backStacks[state.topLevelRoute]?.add(route) - } - } - fun goBack() { /* ... */ } -} -``` - -### 3.2 Multi-Module Navigation - -Nav 3 is designed for modular navigation. Each feature module exposes: -- **`api` module**: Navigation keys (routes) -- **`impl` module**: Entry builders (content) - -```kotlin -// feature/chat/impl -fun EntryProviderScope.chatEntryBuilder() { - entry { key -> ChatScreen(key.conversationId) } - entry { ChatSettingsScreen() } -} - -// app module -NavDisplay( - entryProvider = entryProvider { - chatEntryBuilder() - authEntryBuilder() - settingsEntryBuilder() - } -) -``` - -**With Koin DI** (from nav3-recipes): -```kotlin -// Each feature module provides its entry builder -val chatNavigationModule = module { - factory.() -> Unit>(named("chat")) { - { chatEntryBuilder(get()) } - } -} - -// App module collects all builders -val entryBuilders: List.() -> Unit> = getAll() -NavDisplay( - entryProvider = entryProvider { - entryBuilders.forEach { builder -> this.builder() } - } -) -``` - -### 3.3 ViewModel Scoping - -ViewModels are scoped to `NavEntry` lifecycle via decorators: - -```kotlin -NavDisplay( - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), // Always first - rememberViewModelStoreNavEntryDecorator() - ), - // ... -) -``` - -With this setup: -- `viewModel()` calls inside NavEntry content are scoped to that entry -- ViewModels survive config changes -- ViewModels are cleared when the entry is popped - -**With Koin:** -```kotlin -entry { key -> - val viewModel = koinViewModel() - ChatScreen(viewModel) -} -``` - -### 3.4 Multiple Back Stacks (Bottom Navigation) - -For apps with bottom navigation / top-level routes: - -```kotlin -val topLevelRoutes = setOf(Home, Search, Profile) - -// Each top-level route gets its own persistent back stack -val backStacks = topLevelRoutes.associateWith { rememberNavBackStack(it) } - -// Switch between stacks (state is retained) -var currentTopLevel by remember { mutableStateOf(Home) } -``` - -Recipe: [Multiple back stacks](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/multiplestacks) - -### 3.5 Adaptive / Multi-Pane Layouts - -Use Material 3 Adaptive for responsive list-detail: - -```kotlin -val listDetailStrategy = rememberListDetailSceneStrategy() - -NavDisplay( - backStack = backStack, - sceneStrategies = listOf(listDetailStrategy), - entryProvider = entryProvider { - entry( - metadata = ListDetailSceneStrategy.listPane( - detailPlaceholder = { Text("Select a conversation") } - ) - ) { ConversationListScreen() } - - entry( - metadata = ListDetailSceneStrategy.detailPane() - ) { key -> ConversationDetailScreen(key.id) } - } -) -``` - -### 3.6 Testing Navigation - -**Nav 3 has no dedicated testing library like Nav 2's `navigation-testing`.** However, since navigation state is just a list, testing is simpler: - -```kotlin -@Test -fun testNavigation() { - val backStack = mutableStateListOf(Home) - - // Simulate navigation - backStack.add(ProductDetail("123")) - assertEquals(ProductDetail("123"), backStack.last()) - - // Simulate back - backStack.removeLastOrNull() - assertEquals(Home, backStack.last()) -} -``` - -For UI testing, you can use standard Compose testing: -```kotlin -@Test -fun testNavigationUI() { - composeTestRule.setContent { - val backStack = remember { mutableStateListOf(Home) } - NavDisplay( - backStack = backStack, - entryProvider = entryProvider { /* ... */ } - ) - } - // Assert on rendered content -} -``` - -The `SceneStrategyScope` has a no-arg public constructor specifically for testing (added in 1.0.0-beta01). - ---- - -## 4. Migration Guide (Nav 2 → Nav 3) - -### 4.1 Prerequisites - -- `compileSdk` 36+ -- `minSdk` 23+ -- All destinations must be composable functions -- Routes must implement `NavKey` - -### 4.2 Can Nav 2 and Nav 3 Coexist? - -**Yes.** The official migration guide mentions **atomic migration** as the recommended approach, but community sources confirm **incremental migration** is possible: - -- Nav 2 and Nav 3 can operate simultaneously during migration -- Features can be migrated one at a time -- Business logic and ViewModels don't need rewriting - -However, the official migration guide assumes atomic migration. For incremental, you'd maintain a Nav 2 `NavHost` for unmigrated features and a Nav 3 `NavDisplay` for migrated ones, coordinating between them. - -### 4.3 Step-by-Step Migration - -#### Step 1: Add Dependencies - -```toml -[versions] -nav3Core = "1.0.1" -lifecycleViewmodelNav3 = "2.11.0-alpha03" - -[libraries] -androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } -androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } -androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } -``` - -#### Step 2: Update Routes to Implement NavKey - -```kotlin -// Before -@Serializable data object Home -@Serializable data class Chat(val conversationId: String) - -// After -@Serializable data object Home : NavKey -@Serializable data class Chat(val conversationId: String) : NavKey -``` - -#### Step 3: Create NavigationState & Navigator - -See [Section 3.1](#31-architecture-pattern) for the full pattern. - -#### Step 4: Replace NavController Calls - -| Nav 2 | Nav 3 | -|-------|-------| -| `navController.navigate(route)` | `navigator.navigate(route)` or `backStack.add(route)` | -| `navController.popBackStack()` | `navigator.goBack()` or `backStack.removeLastOrNull()` | -| `navController.currentDestination` | `backStack.last()` | -| `currentBackStackEntryAsState()` | Direct state observation of `backStack` | -| `destination.isRouteInHierarchy()` | `key == navigationState.topLevelRoute` | - -#### Step 5: Convert Destinations - -| Nav 2 | Nav 3 | -|-------|-------| -| `composable { }` | `entry { }` | -| `dialog { }` | `entry(metadata = DialogSceneStrategy.dialog()) { }` | -| `navigation(startDestination = ...) { }` | **Delete** — replace references with first child route | -| `NavGraphBuilder` extension | `EntryProviderScope` extension | -| `entry.toRoute()` | `key` parameter: `entry { key -> ... }` | - -**Before:** -```kotlin -NavHost(navController = navController, startDestination = Home) { - composable { HomeScreen() } - composable { entry -> - val route = entry.toRoute() - ChatScreen(route.conversationId) - } - dialog { ConfirmDialogScreen() } -} -``` - -**After:** -```kotlin -NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - sceneStrategies = listOf(DialogSceneStrategy()), - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() - ), - entryProvider = entryProvider { - entry { HomeScreen() } - entry { key -> ChatScreen(key.conversationId) } - entry( - metadata = DialogSceneStrategy.dialog() - ) { ConfirmDialogScreen() } - } -) -``` - -#### Step 6: Remove Nav 2 Dependencies - -Remove all Nav 2 imports, `NavController`, `NavHost`, `NavGraphBuilder`, and gradle dependencies. - -### 4.4 Key Differences to Watch - -1. **No NavController** — State is a mutable list, not a controller object -2. **No nested graphs** — Delete `navigation` blocks entirely -3. **No built-in deep links** — Must implement manually (see recipes) -4. **Route args via key param** — `entry { key -> ... }` instead of `toRoute()` -5. **Dialogs via metadata** — `DialogSceneStrategy.dialog()` metadata instead of `dialog` -6. **Use `rememberSerializable`, not `rememberSaveable`** — For top-level route persistence -7. **Decorators are explicit** — Must add state/ViewModel decorators manually - ---- - -## 5. Gotchas, Footguns & Complications - -### 5.1 Known Issues (from release notes) - -| Issue | Status | Versions | -|-------|--------|----------| -| z-order bug during animation interruptions ([b/459419800](https://issuetracker.google.com/issues/459419800)) | Fixed in 1.1.0-rc01 | | -| Lifecycle not capped at STARTED for entries under overlays ([b/483966071](https://issuetracker.google.com/issues/483966071)) | Fixed in 1.1.0-rc01 | | -| Crash with SharedTransitionLayout + OverlayScene ([b/478664101](https://issuetracker.google.com/issues/478664101)) | Fixed in 1.1.0-alpha04 | | -| IllegalStateException in Android Studio Previews ([b/477149762](https://issuetracker.google.com/issues/477149762)) | Fixed in 1.0.1 / NavigationEvent 1.0.2 | | -| Infinite scene recalculation ([b/418153031](https://issuetracker.google.com/issues/418153031)) | Fixed in 1.0.0-alpha07 | | -| Screen flicker when swapping backStack with animations ([b/450967248](https://issuetracker.google.com/issues/450967248)) | Fixed in 1.0.0-beta01 | | - -### 5.2 Footguns - -1. **Decorator order matters** — `rememberSaveableStateHolderNavEntryDecorator()` must be **first** in the decorators list. Otherwise, `rememberSaveable` won't work inside NavEntry content. - -2. **Animations in adaptive layouts** — When using `ListDetailSceneStrategy` in two-pane mode (>=600dp), transition animations between top-level destinations may not work. The screen "jumps" without animation. - -3. **No built-in deep link handling** — Unlike Nav 2's `deepLinks { }` DSL, you must parse intents and build synthetic back stacks yourself. See the recipes repo for patterns. - -4. **`rememberNavBackStack` requires NavKey** — If you want persistence across process death, all keys must implement `NavKey` and be `@Serializable`. Simple `data object` without `NavKey` will work but won't survive process death. - -5. **compileSdk 36 required** — This is a hard requirement. If your project is on an older compileSdk, you must upgrade. - -6. **No nested navigation (>1 level)** — The migration guide explicitly states: only one level of nesting is supported. Shared destinations (screens moving between different back stacks) are also unsupported. - -7. **`calculateScene` is no longer `@Composable` (since 1.0.0-alpha11)** — Custom SceneStrategies cannot call composable functions in `calculateScene`. Use `remember` in a factory function instead. - -8. **NavBackStack serialization is Android-only** — The built-in `NavBackStackSerializer` is restricted to Android ([b/420443609](https://issuetracker.google.com/issues/420443609)). For KMP, you need polymorphic serialization (see Section 6). - -9. **API churn in 1.1.x** — The 1.1 branch is still in RC. APIs like `SceneStrategy` receiving `List` instead of chained strategies changed in alpha05. Pin to 1.0.1 for stability. - -### 5.3 Things Harder in Nav 3 - -- **Deep linking**: Significantly more manual work required -- **Bottom sheet destinations**: No built-in support; recipe available but requires custom SceneStrategy -- **Fragment/View interop**: Must use Views-in-Compose wrappers; Nav 3 is Compose-only -- **Shared destinations between back stacks**: Not supported -- **Testing**: No dedicated testing artifact; testing is easier conceptually (just a list) but lacks the convenience APIs of `TestNavHostController` - -### 5.4 Things Easier in Nav 3 - -- **Back stack inspection**: It's just a list — fully observable and debuggable -- **Multi-pane/adaptive layouts**: First-class support via Scenes -- **Custom navigation logic**: No fighting the NavController -- **State management**: Aligns perfectly with Compose state -- **ViewModel scoping**: Explicit, clear, and configurable via decorators - ---- - -## 6. KMP / Compose Multiplatform Considerations - -### 6.1 Is Nav 3 Available for Compose Multiplatform? - -**Yes.** JetBrains added Nav 3 support in **Compose Multiplatform 1.10.0** (January 2026) for all platforms: Android, iOS, desktop, and web. - -### 6.2 Dependencies (KMP) - -```toml -[versions] -multiplatform-nav3-ui = "1.0.0-alpha05" - -[libraries] -jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } -``` - -Nav 3 ships as two artifacts: -- `navigation3-common` — Core (transitive dependency) -- `navigation3-ui` — NavDisplay and UI components (separate CMP implementation) - -Additional KMP libraries: -```toml -# Material 3 Adaptive -jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } - -# ViewModel support -jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } -``` - -### 6.3 Critical KMP Requirement: Polymorphic Serialization - -**On Android**, Nav 3 uses reflection-based serialization for `NavKey` types. **This does NOT work on non-JVM platforms (iOS, web).** You must use polymorphic serialization. - -`rememberNavBackStack()` has two overloads: -1. **Android-only**: `rememberNavBackStack(startKey)` — uses reflection -2. **Multiplatform**: `rememberNavBackStack(config, startKey)` — requires `SavedStateConfiguration` - -**For KMP, you MUST use the second overload:** - -```kotlin -@Serializable -data object Home : NavKey - -@Serializable -data class Chat(val id: String) : NavKey - -// Required for non-JVM platforms -private val config = SavedStateConfiguration { - serializersModule = SerializersModule { - polymorphic(NavKey::class) { - subclass(Home::class, Home.serializer()) - subclass(Chat::class, Chat.serializer()) - } - } -} - -@Composable -fun App() { - val backStack = rememberNavBackStack(config, Home) - NavDisplay(backStack = backStack, ...) -} -``` - -### 6.4 Multi-Module KMP Pattern - -For our multi-module setup, use sealed interfaces per feature module: - -```kotlin -// feature/auth/api -@Serializable -sealed interface AuthRoute : NavKey - -@Serializable data object Login : AuthRoute -@Serializable data object Register : AuthRoute - -// feature/chat/api -@Serializable -sealed interface ChatRoute : NavKey - -@Serializable data class Conversation(val id: String) : ChatRoute - -// app module — aggregate all feature routes -private val config = SavedStateConfiguration { - serializersModule = SerializersModule { - polymorphic(NavKey::class) { - subclassesOfSealed() - subclassesOfSealed() - } - } -} -``` - -Alternatively, with Koin DI, each module can expose a `SerializersModule` and the app module combines them: - -```kotlin -// Each feature module -val chatSerializerModule = SerializersModule { - polymorphic(NavKey::class) { - subclass(Conversation::class, Conversation.serializer()) - } -} - -// App module -val combinedConfig = SavedStateConfiguration { - serializersModule = chatSerializerModule + authSerializerModule + settingsSerializerModule -} -``` - -### 6.5 Impact on Our Current Setup - -We currently use `org.jetbrains.androidx.navigation:navigation-compose:2.9.2`. For Nav 3: - -| Current (Nav 2 KMP) | Nav 3 KMP | -|---------------------|-----------| -| `org.jetbrains.androidx.navigation:navigation-compose:2.9.2` | `org.jetbrains.androidx.navigation3:navigation3-ui:1.0.0-alpha05` | -| `NavHost` | `NavDisplay` | -| `NavController` | `SnapshotStateList` + custom Navigator | -| Reflection-based routing | **Must use** `SavedStateConfiguration` with polymorphic serialization | -| Direct route access | Key-based entry access | - -**Key considerations:** -- The JetBrains Nav 3 artifact is at **1.0.0-alpha05** (not yet stable for KMP) -- Android-side Nav 3 is stable at 1.0.1 -- We must implement polymorphic serialization for all routes -- Our Koin-based DI can provide serializer modules per feature -- Browser history navigation (for web target) is expected in Nav 3 KMP 1.1.0 - -### 6.6 CMP Nav 3 Recipes - -JetBrains maintains a Compose Multiplatform fork of the nav3-recipes: -- [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes) - ---- - -## 7. Appendix: Version & Dependency Reference - -### AndroidX Dependencies (Android-only) - -```toml -[versions] -nav3Core = "1.0.1" # Stable -# nav3Core = "1.1.0-rc01" # Latest RC -lifecycleViewmodelNav3 = "2.11.0-alpha03" -material3AdaptiveNav3 = "1.3.0-alpha09" -kotlinxSerializationCore = "1.9.0" - -[libraries] -androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } -androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } -androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } -androidx-material3-adaptive-navigation3 = { module = "androidx.compose.material3.adaptive:adaptive-navigation3", version.ref = "material3AdaptiveNav3" } -kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } -``` - -### Compose Multiplatform Dependencies (KMP) - -```toml -[versions] -multiplatform-nav3-ui = "1.0.0-alpha05" -compose-multiplatform-adaptive = "1.3.0-alpha02" -compose-multiplatform-lifecycle = "2.10.0-alpha05" - -[libraries] -jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } -jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } -jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } -``` - -### Nav 3 Recipes Repository - -- Android: [github.com/android/nav3-recipes](https://github.com/android/nav3-recipes) (1.2k stars) -- CMP: [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes) - -Available recipes: -- Basic, Saveable back stack, Entry provider DSL -- Deep links (basic + advanced) -- Dialog, BottomSheet, List-Detail, Two-pane, Supporting Pane -- Animations -- Navigation UI, Multiple back stacks -- Conditional navigation -- Modularized navigation (Hilt + Koin) -- ViewModel patterns (basic, Hilt, Koin, shared) -- Retain values for back stack composables -- Returning results (events + state) -- Fragment/View interop - -### Sources - -- [Navigation 3 Overview](https://developer.android.com/guide/navigation/navigation-3) -- [Navigation 3 Basics](https://developer.android.com/guide/navigation/navigation-3/basics) -- [Navigation 3 Migration Guide](https://developer.android.com/guide/navigation/navigation-3/migration-guide) -- [Navigation 3 Save State](https://developer.android.com/guide/navigation/navigation-3/save-state) -- [Navigation 3 Metadata](https://developer.android.com/guide/navigation/navigation-3/metadata) -- [Navigation 3 Modularize](https://developer.android.com/guide/navigation/navigation-3/modularize) -- [Navigation 3 Scenes](https://developer.android.com/guide/navigation/navigation-3/scenes) -- [Navigation 3 Animations](https://developer.android.com/guide/navigation/navigation-3/animate-destinations) -- [Navigation 3 Entry Decorators](https://developer.android.com/guide/navigation/navigation-3/naventrydecorators) -- [Navigation 3 Get Started](https://developer.android.com/guide/navigation/navigation-3/get-started) -- [Navigation 3 Release Notes](https://developer.android.com/jetpack/androidx/releases/navigation3) -- [Navigation 3 in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html) -- [Compose Multiplatform 1.10.0 Blog Post](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/) -- [Jetpack Navigation 3 is Stable (Blog)](https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html) -- [Announcing Jetpack Navigation 3 (Blog)](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html) -- [nav3-recipes GitHub](https://github.com/android/nav3-recipes) -- [Deep Link Guide](https://github.com/android/nav3-recipes/blob/main/docs/deeplink-guide.md) -- [Shared ViewModel Recipe](https://developer.android.com/guide/navigation/navigation-3/recipes/sharedviewmodel)