From 5b1740e58d178ffa3b90114f5f36ec37eca9864f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 18 Jun 2025 09:07:36 +0200 Subject: [PATCH 01/28] Add nav3 dependencies --- gradle/libs.versions.toml | 7 ++++++- shared/build.gradle.kts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d1bb5de..c0f9af1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,8 @@ markdown = "0.37.0" multiplatform-settings = "1.3.0" postgresql = "42.7.7" slf4jNop = "2.0.17" +nav3Core = "1.0.0-alpha10" +lifecycleViewmodelNav3 = "2.10.0-alpha04" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } @@ -39,9 +41,12 @@ android-svg = { module = "com.caverock:androidsvg-aar", version.ref = "android-s androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +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-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } -androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 59407f14..d1cf8976 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -83,7 +83,9 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.compose.ui.backhandler) implementation(libs.ktor.client.core) From 29136ca84d9ff3e2cf4de2c4a8e6da794aa0df06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 11:58:39 +0200 Subject: [PATCH 02/28] Disable non-Android platforms --- shared/build.gradle.kts | 66 +++++++++++++------------- ui-components-gallery/build.gradle.kts | 36 +++++++------- ui-components/build.gradle.kts | 26 +++++----- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d1cf8976..1443354e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,39 +27,39 @@ kotlin { } } - jvm() - - @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) - wasmJs { - binaries.executable() - browser { - val projectDir = project.projectDir.path - val rootDir = project.rootDir.path - commonWebpackConfig { - outputFileName = "kotlinconf-app.js" - } - } - } - - js { - binaries.executable() - browser { - commonWebpackConfig { - outputFileName = "kotlinconf-app.js" - } - } - } - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64() - ).forEach { - it.binaries.framework { - baseName = "shared" - isStatic = true - } - } +// jvm() +// +// @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) +// wasmJs { +// binaries.executable() +// browser { +// val projectDir = project.projectDir.path +// val rootDir = project.rootDir.path +// commonWebpackConfig { +// outputFileName = "kotlinconf-app.js" +// } +// } +// } +// +// js { +// binaries.executable() +// browser { +// commonWebpackConfig { +// outputFileName = "kotlinconf-app.js" +// } +// } +// } +// +// listOf( +// iosX64(), +// iosArm64(), +// iosSimulatorArm64() +// ).forEach { +// it.binaries.framework { +// baseName = "shared" +// isStatic = true +// } +// } applyDefaultHierarchyTemplate() diff --git a/ui-components-gallery/build.gradle.kts b/ui-components-gallery/build.gradle.kts index f02d446f..38f20383 100644 --- a/ui-components-gallery/build.gradle.kts +++ b/ui-components-gallery/build.gradle.kts @@ -10,24 +10,24 @@ plugins { kotlin { androidTarget() - jvm() - - iosX64() - iosArm64() - iosSimulatorArm64() - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - binaries.executable() - browser { - commonWebpackConfig { - outputFileName = "kotlinconf-app-gallery.js" - } - } - } - js { - browser() - } +// jvm() +// +// iosX64() +// iosArm64() +// iosSimulatorArm64() +// +// @OptIn(ExperimentalWasmDsl::class) +// wasmJs { +// binaries.executable() +// browser { +// commonWebpackConfig { +// outputFileName = "kotlinconf-app-gallery.js" +// } +// } +// } +// js { +// browser() +// } sourceSets { commonMain.dependencies { diff --git a/ui-components/build.gradle.kts b/ui-components/build.gradle.kts index d37159a9..b9bd26f8 100644 --- a/ui-components/build.gradle.kts +++ b/ui-components/build.gradle.kts @@ -15,19 +15,19 @@ kotlin { androidTarget() - jvm() - - iosX64() - iosArm64() - iosSimulatorArm64() - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - browser() - } - js { - browser() - } +// jvm() +// +// iosX64() +// iosArm64() +// iosSimulatorArm64() +// +// @OptIn(ExperimentalWasmDsl::class) +// wasmJs { +// browser() +// } +// js { +// browser() +// } sourceSets { commonMain.dependencies { From ce1a4af486588129c5f968ae33888f23f30d3af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 18 Jun 2025 09:46:54 +0200 Subject: [PATCH 03/28] Initial attempt at some nav3 navigation --- .../navigation/KotlinConfNavHost.kt | 295 +++++++++--------- .../kotlinconf/screens/MainScreen.kt | 136 ++++---- 2 files changed, 222 insertions(+), 209 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 37476210..63e28dac 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -3,25 +3,20 @@ package org.jetbrains.kotlinconf.navigation import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.platform.LocalUriHandler import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navigation -import androidx.navigation.toRoute +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.channels.Channel -import org.jetbrains.kotlinconf.LocalFlags import org.jetbrains.kotlinconf.LocalNotificationId -import org.jetbrains.kotlinconf.PartnerId import org.jetbrains.kotlinconf.SessionId -import org.jetbrains.kotlinconf.SpeakerId import org.jetbrains.kotlinconf.URLs import org.jetbrains.kotlinconf.screens.AboutAppScreen import org.jetbrains.kotlinconf.screens.AboutConference @@ -43,8 +38,6 @@ import org.jetbrains.kotlinconf.screens.StartNotificationsScreen import org.jetbrains.kotlinconf.screens.VisitorPrivacyNotice import org.jetbrains.kotlinconf.screens.VisitorTermsOfUse import org.jetbrains.kotlinconf.utils.getStoreUrl -import kotlin.jvm.JvmSuppressWildcards -import kotlin.reflect.typeOf fun navigateByLocalNotificationId(notificationId: String) { LocalNotificationId.parse(notificationId)?.let { @@ -77,202 +70,210 @@ internal fun KotlinConfNavHost( popEnterTransition: @JvmSuppressWildcards (AnimatedContentTransitionScope.() -> EnterTransition)?, popExitTransition: @JvmSuppressWildcards (AnimatedContentTransitionScope.() -> ExitTransition)?, ) { - val navController = rememberNavController() +// val navController = rememberNavController() - NotificationHandler(navController) - PlatformNavHandler(navController) +// NotificationHandler(navController) +// PlatformNavHandler(navController) - val startDestination = if (isOnboardingComplete) MainScreen else StartScreens - if (popEnterTransition != null && popExitTransition != null) { - NavHost( - navController = navController, - startDestination = startDestination, - modifier = Modifier.fillMaxSize(), - popEnterTransition = popEnterTransition, - popExitTransition = popExitTransition, - ) { - screens(navController) + val backStack = remember { mutableStateListOf(MainScreen) } + NavDisplay( + backStack = backStack, + entryProvider = entryProvider { + screens(backStack) } - } else { - NavHost( - navController = navController, - startDestination = startDestination, - modifier = Modifier.fillMaxSize(), - ) { - screens(navController) - } - } -} + ) +// val startDestination = if (isOnboardingComplete) MainScreen else StartScreens +// if (popEnterTransition != null && popExitTransition != null) { +// NavHost( +// navController = navController, +// startDestination = startDestination, +// modifier = Modifier.fillMaxSize(), +// popEnterTransition = popEnterTransition, +// popExitTransition = popExitTransition, +// ) { +// screens(navController) +// } +// } else { +// NavHost( +// navController = navController, +// startDestination = startDestination, +// modifier = Modifier.fillMaxSize(), +// ) { +// screens(navController) +// } +// } +} -fun NavGraphBuilder.screens(navController: NavHostController) { - startScreens( - navController = navController, - ) - composable { - MainScreen( - rootNavController = navController, - ) +fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { + fun popBackStack() { + backStack.removeAt(backStack.lastIndex) } - composable(typeMap = mapOf(typeOf() to SpeakerIdNavType)) { +// startScreens( +// navController = navController, +// ) + + entry { + MainScreen(onNavigate = { backStack.add(it) }) + } + entry { SpeakerDetailScreen( - speakerId = it.toRoute().speakerId, - onBack = navController::navigateUp, - onSession = { navController.navigate(SessionScreen(it)) }, + speakerId = it.speakerId, + onBack = { popBackStack() }, + onSession = { backStack.add(SessionScreen(it)) }, + ) + } + entry { + val urlHandler = LocalUriHandler.current + SessionScreen( + sessionId = it.sessionId, + openedForFeedback = it.openedForFeedback, + onBack = { popBackStack() }, + onPrivacyNoticeNeeded = { backStack.add(AppPrivacyNoticePrompt) }, + onSpeaker = { speakerId -> backStack.add(SpeakerDetailScreen(speakerId)) }, + onWatchVideo = { videoUrl -> urlHandler.openUri(videoUrl) }, + onNavigateToMap = { roomName -> + backStack.add(NestedMapScreen(roomName)) + }, ) } - composable { + + entry { val uriHandler = LocalUriHandler.current AboutAppScreen( - onBack = navController::navigateUp, + onBack = { popBackStack() }, onGitHubRepo = { uriHandler.openUri(URLs.GITHUB_REPO) }, onRateApp = { getStoreUrl()?.let { uriHandler.openUri(it) } }, - onPrivacyNotice = { navController.navigate(AppPrivacyNoticeScreen) }, - onTermsOfUse = { navController.navigate(AppTermsOfUseScreen) }, - onLicenses = { navController.navigate(LicensesScreen) }, + onPrivacyNotice = { backStack.add(AppPrivacyNoticeScreen) }, + onTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, + onLicenses = { backStack.add(LicensesScreen) }, onJunie = { uriHandler.openUri(URLs.JUNIE_LANDING_PAGE) }, - onDeveloperMenu = { navController.navigate(DeveloperMenuScreen) }, + onDeveloperMenu = { backStack.add(DeveloperMenuScreen) }, ) } - composable { + entry { LicensesScreen( onLicenseClick = { licenseName, licenseText -> - navController.navigate(SingleLicenseScreen(licenseName, licenseText)) + backStack.add(SingleLicenseScreen(licenseName, licenseText)) }, - onBack = navController::navigateUp, + onBack = { popBackStack() }, ) } - composable { - val params = it.toRoute() + entry { SingleLicenseScreen( - licenseName = params.licenseName, - licenseContent = params.licenseText, - onBack = navController::navigateUp, + licenseName = it.licenseName, + licenseContent = it.licenseText, + onBack = { popBackStack() }, ) } - composable { + entry { val urlHandler = LocalUriHandler.current AboutConference( - onPrivacyNotice = { navController.navigate(VisitorPrivacyNoticeScreen) }, - onGeneralTerms = { navController.navigate(TermsOfUseScreen) }, + onPrivacyNotice = { backStack.add(VisitorPrivacyNoticeScreen) }, + onGeneralTerms = { backStack.add(TermsOfUseScreen) }, onWebsiteLink = { urlHandler.openUri(URLs.KOTLINCONF_HOMEPAGE) }, - onBack = navController::navigateUp, - onSpeaker = { speakerId -> navController.navigate(SpeakerDetailScreen(speakerId)) }, + onBack = { popBackStack() }, + onSpeaker = { speakerId -> backStack.add(SpeakerDetailScreen(speakerId)) }, ) } - composable { - CodeOfConduct(onBack = navController::navigateUp) + entry { + CodeOfConduct(onBack = { popBackStack() }) } - composable { - SettingsScreen(onBack = navController::navigateUp) + entry { + SettingsScreen(onBack = { popBackStack() }) } - composable { - VisitorPrivacyNotice(onBack = navController::navigateUp) + entry { + VisitorPrivacyNotice(onBack = { popBackStack() }) } - composable { + entry { AppPrivacyNotice( - onBack = navController::navigateUp, - onAppTermsOfUse = { navController.navigate(AppTermsOfUseScreen) }, + onBack = { popBackStack() }, + onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, ) } - composable { + entry { VisitorTermsOfUse( - onBack = navController::navigateUp, - onCodeOfConduct = { navController.navigate(CodeOfConductScreen) }, - onVisitorPrivacyNotice = { navController.navigate(VisitorPrivacyNoticeScreen) }, + onBack = { popBackStack() }, + onCodeOfConduct = { backStack.add(CodeOfConductScreen) }, + onVisitorPrivacyNotice = { backStack.add(VisitorPrivacyNoticeScreen) }, ) } - composable { + entry { AppTermsOfUse( - onBack = navController::navigateUp, + onBack = { popBackStack() }, onAppPrivacyNotice = { - navController.navigate(AppPrivacyNoticeScreen) + backStack.add(AppPrivacyNoticeScreen) }, ) } - composable { + entry { PartnersScreen( - onBack = navController::navigateUp, + onBack = { popBackStack() }, onPartnerDetail = { partnerId -> - navController.navigate(PartnerDetailScreen(partnerId)) + backStack.add(PartnerDetailScreen(partnerId)) } ) } - composable(typeMap = mapOf(typeOf() to PartnerIdNavType)) { + entry { PartnerDetailScreen( - partnerId = it.toRoute().partnerId, - onBack = navController::navigateUp, + partnerId = it.partnerId, + onBack = { popBackStack() }, ) } - composable(typeMap = mapOf(typeOf() to SessionIdNavType)) { - val params = it.toRoute() - val urlHandler = LocalUriHandler.current - SessionScreen( - sessionId = params.sessionId, - openedForFeedback = params.openedForFeedback, - onBack = navController::navigateUp, - onPrivacyNoticeNeeded = { navController.navigate(AppPrivacyNoticePrompt) }, - onSpeaker = { speakerId -> navController.navigate(SpeakerDetailScreen(speakerId)) }, - onWatchVideo = { videoUrl -> urlHandler.openUri(videoUrl) }, - onNavigateToMap = { roomName -> - navController.navigate(NestedMapScreen(roomName)) - }, - ) - } - composable { + + entry { AppPrivacyNoticePrompt( - onRejectNotice = navController::navigateUp, - onAcceptNotice = navController::navigateUp, - onAppTermsOfUse = { navController.navigate(AppTermsOfUseScreen) }, + onRejectNotice = { popBackStack() }, + onAcceptNotice = { popBackStack() }, + onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, confirmationRequired = true, ) } - composable { - DeveloperMenuScreen(onBack = navController::navigateUp) + entry { + DeveloperMenuScreen(onBack = { popBackStack() }) } - composable { - val nestedMapParam = it.toRoute() + entry { NestedMapScreen( - roomName = nestedMapParam.roomName, - onBack = navController::navigateUp, + roomName = it.roomName, + onBack = { popBackStack() }, ) } } -fun NavGraphBuilder.startScreens( - navController: NavHostController, -) { - navigation( - startDestination = StartPrivacyNoticeScreen - ) { - composable { - val skipNotifications = LocalFlags.current.supportsNotifications.not() - AppPrivacyNoticePrompt( - onRejectNotice = { - navController.navigate(if (skipNotifications) MainScreen else StartNotificationsScreen) { - popUpTo { inclusive = skipNotifications } - } - }, - onAcceptNotice = { - navController.navigate(if (skipNotifications) MainScreen else StartNotificationsScreen) { - popUpTo { inclusive = skipNotifications } - } - }, - onAppTermsOfUse = { navController.navigate(AppTermsOfUseScreen) }, - confirmationRequired = false, - ) - } - composable { - StartNotificationsScreen( - onDone = { - navController.navigate(MainScreen) { - popUpTo { inclusive = true } - } - }) - } - } -} +//fun NavGraphBuilder.startScreens( +// navController: NavHostController, +//) { +// navigation( +// startDestination = StartPrivacyNoticeScreen +// ) { +// composable { +// val skipNotifications = LocalFlags.current.supportsNotifications.not() +// AppPrivacyNoticePrompt( +// onRejectNotice = { +// navController.navigate(if (skipNotifications) MainScreen else StartNotificationsScreen) { +// popUpTo { inclusive = skipNotifications } +// } +// }, +// onAcceptNotice = { +// navController.navigate(if (skipNotifications) MainScreen else StartNotificationsScreen) { +// popUpTo { inclusive = skipNotifications } +// } +// }, +// onAppTermsOfUse = { navController.navigate(AppTermsOfUseScreen) }, +// confirmationRequired = false, +// ) +// } +// composable { +// StartNotificationsScreen( +// onDone = { +// navController.navigate(MainScreen) { +// popUpTo { inclusive = true } +// } +// }) +// } +// } +//} diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index a213272b..bcc9f7ed 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -1,8 +1,6 @@ package org.jetbrains.kotlinconf.screens import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.snap import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -16,13 +14,31 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import kotlinconfapp.shared.generated.resources.Res +import kotlinconfapp.shared.generated.resources.clock_28 +import kotlinconfapp.shared.generated.resources.clock_28_fill +import kotlinconfapp.shared.generated.resources.info_28 +import kotlinconfapp.shared.generated.resources.info_28_fill +import kotlinconfapp.shared.generated.resources.location_28 +import kotlinconfapp.shared.generated.resources.location_28_fill +import kotlinconfapp.shared.generated.resources.nav_destination_info +import kotlinconfapp.shared.generated.resources.nav_destination_map +import kotlinconfapp.shared.generated.resources.nav_destination_schedule +import kotlinconfapp.shared.generated.resources.nav_destination_speakers +import kotlinconfapp.shared.generated.resources.team_28 +import kotlinconfapp.shared.generated.resources.team_28_fill import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavGraph.Companion.findStartDestination @@ -54,6 +70,7 @@ import org.jetbrains.kotlinconf.navigation.AppPrivacyNoticePrompt import org.jetbrains.kotlinconf.navigation.CodeOfConductScreen import org.jetbrains.kotlinconf.navigation.InfoScreen import org.jetbrains.kotlinconf.navigation.MapScreen +import org.jetbrains.kotlinconf.navigation.NewsListScreen import org.jetbrains.kotlinconf.navigation.PartnersScreen import org.jetbrains.kotlinconf.navigation.ScheduleScreen import org.jetbrains.kotlinconf.navigation.SessionScreen @@ -68,7 +85,7 @@ import org.koin.compose.koinInject @Composable fun MainScreen( - rootNavController: NavController, + onNavigate: (Any) -> Unit, service: ConferenceService = koinInject(), ) { LaunchedEffect(Unit) { @@ -81,56 +98,53 @@ fun MainScreen( .background(color = KotlinConfTheme.colors.mainBackground) .windowInsetsPadding(WindowInsets.safeDrawing) ) { - val nestedNavController = rememberNavController() - NavHost( - nestedNavController, - startDestination = ScheduleScreen, + val localBackStack = remember { mutableStateListOf(ScheduleScreen) } + NavDisplay( + backStack = localBackStack, modifier = Modifier .fillMaxWidth() .weight(1f), - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }, - ) { - composable { - MainBackHandler() - val uriHandler = LocalUriHandler.current - InfoScreen( - onAboutConf = { rootNavController.navigate(AboutConferenceScreen) }, - onAboutApp = { rootNavController.navigate(AboutAppScreen) }, - onOurPartners = { rootNavController.navigate(PartnersScreen) }, - onCodeOfConduct = { rootNavController.navigate(CodeOfConductScreen) }, - onTwitter = { uriHandler.openUri(URLs.TWITTER) }, - onSlack = { uriHandler.openUri(URLs.SLACK) }, - onBluesky = { uriHandler.openUri(URLs.BLUESKY) }, - onSettings = { rootNavController.navigate(SettingsScreen) }, - ) - } - composable { - MainBackHandler() - SpeakersScreen( - onSpeaker = { rootNavController.navigate(SpeakerDetailScreen(it)) } - ) - } - composable { - MainBackHandler() - ScheduleScreen( - onSession = { rootNavController.navigate(SessionScreen(it)) }, - onPrivacyNoticeNeeded = { rootNavController.navigate(AppPrivacyNoticePrompt) }, - onRequestFeedbackWithComment = { sessionId -> - rootNavController.navigate(SessionScreen(sessionId, openedForFeedback = true)) - }, - ) - } - composable { - MainBackHandler() - MapScreen() + entryProvider = entryProvider { + entry { + MainBackHandler() + val uriHandler = LocalUriHandler.current + InfoScreen( + onAboutConf = { onNavigate(AboutConferenceScreen) }, + onAboutApp = { onNavigate(AboutAppScreen) }, + onNewsFeed = { onNavigate(NewsListScreen) }, + onOurPartners = { onNavigate(PartnersScreen) }, + onCodeOfConduct = { onNavigate(CodeOfConductScreen) }, + onTwitter = { uriHandler.openUri(URLs.TWITTER) }, + onSlack = { uriHandler.openUri(URLs.SLACK) }, + onBluesky = { uriHandler.openUri(URLs.BLUESKY) }, + onSettings = { onNavigate(SettingsScreen) }, + ) + } + entry { + MainBackHandler() + SpeakersScreen( + onSpeaker = { onNavigate(SpeakerDetailScreen(it)) } + ) + } + entry { + MainBackHandler() + ScheduleScreen( + onSession = { onNavigate(SessionScreen(it)) }, + onPrivacyNoticeNeeded = { onNavigate(AppPrivacyNoticePrompt) }, + onRequestFeedbackWithComment = { sessionId -> + onNavigate(SessionScreen(sessionId, openedForFeedback = true)) + }, + ) + } + entry { + MainBackHandler() + MapScreen() + } } - } + ) AnimatedVisibility(!isKeyboardOpen(), enter = fadeIn(snap()), exit = fadeOut(snap())) { - BottomNavigation(nestedNavController) + BottomNavigation(localBackStack) } } } @@ -151,7 +165,7 @@ private fun isKeyboardOpen(): Boolean { } @Composable -private fun BottomNavigation(nestedNavController: NavHostController) { +private fun BottomNavigation(localBackStack: SnapshotStateList) { val bottomNavDestinations: List = listOf( MainNavDestination( @@ -184,12 +198,9 @@ private fun BottomNavigation(nestedNavController: NavHostController) { ), ) - val currentDestination = nestedNavController.currentBackStackEntryAsState().value?.destination - val currentBottomNavDestination = currentDestination?.let { - bottomNavDestinations.find { dest -> - val routeClass = dest.routeClass - routeClass != null && currentDestination.hasRoute(routeClass) - } + val currentDestination = localBackStack.last() + val currentBottomNavDestination = bottomNavDestinations.find { dest -> + currentDestination == dest.route } Divider(thickness = 1.dp, color = KotlinConfTheme.colors.strokePale) @@ -197,14 +208,15 @@ private fun BottomNavigation(nestedNavController: NavHostController) { currentDestination = currentBottomNavDestination, destinations = bottomNavDestinations, onSelect = { - nestedNavController.navigate(it.route) { - // Avoid stacking multiple copies of the main screens - popUpTo(nestedNavController.graph.findStartDestination().route!!) { - saveState = true - } - launchSingleTop = true - restoreState = true - } + localBackStack.add(it.route) + //{ +// // Avoid stacking multiple copies of the main screens +// popUpTo(localBackStack.graph.findStartDestination().route!!) { +// saveState = true +// } +// launchSingleTop = true +// restoreState = true +// } }, ) } From 7ae855f8b5fba7a2d50c11e7f9052565495b5428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 14:09:54 +0200 Subject: [PATCH 04/28] Update transition handling code, main screen nested navigation --- androidApp/build.gradle.kts | 1 + .../kotlinconf/android/MainActivity.kt | 17 +-- .../kotlin/org/jetbrains/kotlinconf/App.kt | 7 +- .../navigation/KotlinConfNavHost.kt | 118 ++++++++---------- .../jetbrains/kotlinconf/navigation/Routes.kt | 14 +-- .../kotlinconf/screens/MainScreen.kt | 34 +++-- 6 files changed, 94 insertions(+), 97 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 82a128c3..5bb612e4 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { implementation(libs.compose.ui.tooling.preview) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.navigation3.ui) } androidUnitTest.dependencies { diff --git a/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt index df6736a2..a90af6e8 100644 --- a/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt @@ -8,6 +8,8 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -48,14 +50,13 @@ class MainActivity : ComponentActivity() { window.isNavigationBarContrastEnforced = false } }, - popEnterTransition = { - scaleIn(initialScale = 1.05f) + - fadeIn(animationSpec = tween(50)) - }, - popExitTransition = { - scaleOut(targetScale = 0.9f, animationSpec = tween(50)) + - fadeOut(animationSpec = tween(50, delayMillis = 50)) - }, + popTransactionSpec = { + // TODO: review these magic numbers from Androidify + ContentTransform( + fadeIn(spring(dampingRatio = 1.0f, stiffness = 1600f)), + scaleOut(targetScale = 0.7f), + ) + } ) } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt index b3b9c4d8..5dc32afd 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt @@ -1,6 +1,7 @@ package org.jetbrains.kotlinconf import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.background @@ -14,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry +import androidx.navigation3.scene.Scene import org.jetbrains.kotlinconf.navigation.KotlinConfNavHost import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme import org.koin.compose.koinInject @@ -22,8 +24,7 @@ import kotlin.jvm.JvmSuppressWildcards @Composable fun App( onThemeChange: ((isDarkTheme: Boolean) -> Unit)? = null, - popEnterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition)? = null, - popExitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition)? = null, + popTransactionSpec: AnimatedContentTransitionScope>.() -> ContentTransform ) { val service = koinInject() val currentTheme by service.getTheme().collectAsStateWithLifecycle(initialValue = Theme.SYSTEM) @@ -53,7 +54,7 @@ fun App( .background(KotlinConfTheme.colors.mainBackground) ) { if (isOnboardingComplete != null) { - KotlinConfNavHost(isOnboardingComplete, popEnterTransition, popExitTransition) + KotlinConfNavHost(isOnboardingComplete, popTransactionSpec) } } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 63e28dac..ee306d5e 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -1,8 +1,10 @@ package org.jetbrains.kotlinconf.navigation import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateListOf @@ -10,11 +12,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.platform.LocalUriHandler import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavHostController import androidx.navigation3.runtime.EntryProviderBuilder import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.Scene import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.channels.Channel +import org.jetbrains.kotlinconf.LocalFlags import org.jetbrains.kotlinconf.LocalNotificationId import org.jetbrains.kotlinconf.SessionId import org.jetbrains.kotlinconf.URLs @@ -55,11 +58,11 @@ fun navigateToSession(sessionId: SessionId) { private val notificationNavRequests = Channel(capacity = 1) @Composable -private fun NotificationHandler(navController: NavHostController) { +private fun NotificationHandler(backStack: SnapshotStateList) { LaunchedEffect(Unit) { while (true) { val destination = notificationNavRequests.receive() - navController.navigate(destination) + backStack.add(destination) } } } @@ -67,42 +70,24 @@ private fun NotificationHandler(navController: NavHostController) { @Composable internal fun KotlinConfNavHost( isOnboardingComplete: Boolean, - popEnterTransition: @JvmSuppressWildcards (AnimatedContentTransitionScope.() -> EnterTransition)?, - popExitTransition: @JvmSuppressWildcards (AnimatedContentTransitionScope.() -> ExitTransition)?, + popTransactionSpec: AnimatedContentTransitionScope>.() -> ContentTransform, ) { -// val navController = rememberNavController() + // TODO: make this saveable! + val backStack: SnapshotStateList = remember { + val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen + mutableStateListOf(startDestination) + } -// NotificationHandler(navController) -// PlatformNavHandler(navController) + NotificationHandler(backStack) + //PlatformNavHandler(navController) - val backStack = remember { mutableStateListOf(MainScreen) } NavDisplay( backStack = backStack, entryProvider = entryProvider { screens(backStack) - } + }, + popTransitionSpec = popTransactionSpec, ) - -// val startDestination = if (isOnboardingComplete) MainScreen else StartScreens -// if (popEnterTransition != null && popExitTransition != null) { -// NavHost( -// navController = navController, -// startDestination = startDestination, -// modifier = Modifier.fillMaxSize(), -// popEnterTransition = popEnterTransition, -// popExitTransition = popExitTransition, -// ) { -// screens(navController) -// } -// } else { -// NavHost( -// navController = navController, -// startDestination = startDestination, -// modifier = Modifier.fillMaxSize(), -// ) { -// screens(navController) -// } -// } } @@ -111,9 +96,7 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { backStack.removeAt(backStack.lastIndex) } -// startScreens( -// navController = navController, -// ) + startScreens(backStack) entry { MainScreen(onNavigate = { backStack.add(it) }) @@ -244,36 +227,37 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { } } -//fun NavGraphBuilder.startScreens( -// navController: NavHostController, -//) { -// navigation( -// startDestination = StartPrivacyNoticeScreen -// ) { -// composable { -// val skipNotifications = LocalFlags.current.supportsNotifications.not() -// AppPrivacyNoticePrompt( -// onRejectNotice = { -// navController.navigate(if (skipNotifications) MainScreen else StartNotificationsScreen) { -// popUpTo { inclusive = skipNotifications } -// } -// }, -// onAcceptNotice = { -// navController.navigate(if (skipNotifications) MainScreen else StartNotificationsScreen) { -// popUpTo { inclusive = skipNotifications } -// } -// }, -// onAppTermsOfUse = { navController.navigate(AppTermsOfUseScreen) }, -// confirmationRequired = false, -// ) -// } -// composable { -// StartNotificationsScreen( -// onDone = { -// navController.navigate(MainScreen) { -// popUpTo { inclusive = true } -// } -// }) -// } -// } -//} +fun EntryProviderBuilder.startScreens(backStack: SnapshotStateList) { + entry { + val skipNotifications = LocalFlags.current.supportsNotifications.not() + AppPrivacyNoticePrompt( + onRejectNotice = { + if (skipNotifications) { + backStack.add(MainScreen) + backStack.removeAll { it !is MainScreen } + } else { + backStack.add(StartNotificationsScreen) + } + }, + onAcceptNotice = { + if (skipNotifications) { + backStack.add(MainScreen) + backStack.removeAll { it !is MainScreen } + } else { + backStack.add(StartNotificationsScreen) + } + }, + onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, + confirmationRequired = false, + ) + } + + entry { + StartNotificationsScreen( + onDone = { + backStack.add(MainScreen) + backStack.removeAll { it !is MainScreen } + } + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt index 8ff00381..a7b9e548 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt @@ -20,11 +20,7 @@ data object AboutAppScreen @Serializable @SerialName("Info") -data object InfoScreen - -@Serializable -@SerialName("Welcome") -data object StartScreens +data object InfoScreen: MainScreenMarker @Serializable @SerialName("WelcomePrivacyNotice") @@ -81,9 +77,11 @@ data class PartnerDetailScreen(val partnerId: PartnerId) @SerialName("Main") data object MainScreen +sealed interface MainScreenMarker + @Serializable @SerialName("Schedule") -data object ScheduleScreen +data object ScheduleScreen : MainScreenMarker @Serializable @SerialName("Session") @@ -94,7 +92,7 @@ data class SessionScreen( @Serializable @SerialName("Speakers") -data object SpeakersScreen +data object SpeakersScreen: MainScreenMarker @Serializable @SerialName("Speaker") @@ -102,7 +100,7 @@ data class SpeakerDetailScreen(val speakerId: SpeakerId) @Serializable @SerialName("Map") -data object MapScreen +data object MapScreen: MainScreenMarker @Serializable @SerialName("MapDetail") diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index bcc9f7ed..cd974e03 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -1,7 +1,9 @@ package org.jetbrains.kotlinconf.screens import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background @@ -69,6 +71,7 @@ import org.jetbrains.kotlinconf.navigation.AboutConferenceScreen import org.jetbrains.kotlinconf.navigation.AppPrivacyNoticePrompt import org.jetbrains.kotlinconf.navigation.CodeOfConductScreen import org.jetbrains.kotlinconf.navigation.InfoScreen +import org.jetbrains.kotlinconf.navigation.MainScreenMarker import org.jetbrains.kotlinconf.navigation.MapScreen import org.jetbrains.kotlinconf.navigation.NewsListScreen import org.jetbrains.kotlinconf.navigation.PartnersScreen @@ -98,12 +101,19 @@ fun MainScreen( .background(color = KotlinConfTheme.colors.mainBackground) .windowInsetsPadding(WindowInsets.safeDrawing) ) { - val localBackStack = remember { mutableStateListOf(ScheduleScreen) } + val localBackStack = remember { mutableStateListOf(ScheduleScreen) } NavDisplay( backStack = localBackStack, modifier = Modifier .fillMaxWidth() .weight(1f), + predictivePopTransitionSpec = { + // TODO choose specs + ContentTransform( + fadeIn(animationSpec = tween(700)), + fadeOut(animationSpec = tween(700)), + ) + }, entryProvider = entryProvider { entry { MainBackHandler() @@ -165,7 +175,7 @@ private fun isKeyboardOpen(): Boolean { } @Composable -private fun BottomNavigation(localBackStack: SnapshotStateList) { +private fun BottomNavigation(localBackStack: SnapshotStateList) { val bottomNavDestinations: List = listOf( MainNavDestination( @@ -208,15 +218,17 @@ private fun BottomNavigation(localBackStack: SnapshotStateList) { currentDestination = currentBottomNavDestination, destinations = bottomNavDestinations, onSelect = { - localBackStack.add(it.route) - //{ -// // Avoid stacking multiple copies of the main screens -// popUpTo(localBackStack.graph.findStartDestination().route!!) { -// saveState = true -// } -// launchSingleTop = true -// restoreState = true -// } + val target = it.route as MainScreenMarker // TODO avoid this cast + if (localBackStack.last() == target) { + return@MainNavigation + } + + localBackStack.add(target) + + if (localBackStack.size > 2) { + // Remove everything but the first and last entry + localBackStack.removeRange(1, localBackStack.lastIndex) + } }, ) } From 037e5fea172825bcb4af9a4e12030ed39cd71ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 14:14:48 +0200 Subject: [PATCH 05/28] Add type param to MainNavigation --- .../org/jetbrains/kotlinconf/screens/MainScreen.kt | 4 ++-- .../kotlinconf/ui/components/MainNavigation.kt | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index cd974e03..11aba88c 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -176,7 +176,7 @@ private fun isKeyboardOpen(): Boolean { @Composable private fun BottomNavigation(localBackStack: SnapshotStateList) { - val bottomNavDestinations: List = + val bottomNavDestinations: List> = listOf( MainNavDestination( label = stringResource(Res.string.nav_destination_schedule), @@ -218,7 +218,7 @@ private fun BottomNavigation(localBackStack: SnapshotStateList currentDestination = currentBottomNavDestination, destinations = bottomNavDestinations, onSelect = { - val target = it.route as MainScreenMarker // TODO avoid this cast + val target = it.route if (localBackStack.last() == target) { return@MainNavigation } diff --git a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt index e8a1ace8..d3b3b5ea 100644 --- a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt +++ b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt @@ -65,19 +65,19 @@ private fun MainNavigationButton( ) } -data class MainNavDestination( +data class MainNavDestination( val label: String, val icon: DrawableResource, - val route: Any, + val route: T, val iconSelected: DrawableResource = icon, val routeClass: KClass<*>? = null, ) @Composable -fun MainNavigation( - currentDestination: MainNavDestination?, - destinations: List, - onSelect: (MainNavDestination) -> Unit, +fun MainNavigation( + currentDestination: MainNavDestination?, + destinations: List>, + onSelect: (MainNavDestination) -> Unit, modifier: Modifier = Modifier, ) { Row( From 337131499896167dd3a2cfc85eb2045c7620f911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 14:19:54 +0200 Subject: [PATCH 06/28] Fix transitions on main screen --- .../jetbrains/kotlinconf/screens/MainScreen.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 11aba88c..d2094496 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -2,8 +2,9 @@ package org.jetbrains.kotlinconf.screens import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background @@ -86,6 +87,8 @@ import org.jetbrains.kotlinconf.ui.components.MainNavigation import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme import org.koin.compose.koinInject +private val NoContentTransition = ContentTransform(EnterTransition.None, ExitTransition.None) + @Composable fun MainScreen( onNavigate: (Any) -> Unit, @@ -107,13 +110,9 @@ fun MainScreen( modifier = Modifier .fillMaxWidth() .weight(1f), - predictivePopTransitionSpec = { - // TODO choose specs - ContentTransform( - fadeIn(animationSpec = tween(700)), - fadeOut(animationSpec = tween(700)), - ) - }, + transitionSpec = { NoContentTransition }, + popTransitionSpec = { NoContentTransition }, + predictivePopTransitionSpec = { NoContentTransition }, entryProvider = entryProvider { entry { MainBackHandler() From df27217b50c4754ef578fd5c1607b2d08a37c8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 14:51:42 +0200 Subject: [PATCH 07/28] Introduce BackStack wrapper --- .../kotlinconf/navigation/BackStack.kt | 29 +++++++ .../navigation/KotlinConfNavHost.kt | 76 ++++++++----------- .../kotlinconf/screens/MainScreen.kt | 33 ++++---- 3 files changed, 77 insertions(+), 61 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt new file mode 100644 index 00000000..fb95ba66 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt @@ -0,0 +1,29 @@ +package org.jetbrains.kotlinconf.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember + +@Composable +fun rememberBackstack(initial: T): BackStack = remember { BackStack(initial) } + +class BackStack(initial: T) { + private val _backStack = mutableStateListOf(initial) + val backStack: List get() = _backStack + + fun edit(actions: MutableList.() -> Unit) { + _backStack.actions() + } + + fun add(element: T, clearOthers: Boolean = false) { + _backStack.add(element) + + if (clearOthers) { + _backStack.removeRange(0, _backStack.lastIndex) + } + } + + fun pop() { + _backStack.removeAt(_backStack.lastIndex) + } +} diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index ee306d5e..aabfcb29 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -2,16 +2,9 @@ package org.jetbrains.kotlinconf.navigation import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.platform.LocalUriHandler -import androidx.navigation.NavBackStackEntry import androidx.navigation3.runtime.EntryProviderBuilder import androidx.navigation3.runtime.entryProvider import androidx.navigation3.scene.Scene @@ -58,7 +51,7 @@ fun navigateToSession(sessionId: SessionId) { private val notificationNavRequests = Channel(capacity = 1) @Composable -private fun NotificationHandler(backStack: SnapshotStateList) { +private fun NotificationHandler(backStack: BackStack) { LaunchedEffect(Unit) { while (true) { val destination = notificationNavRequests.receive() @@ -73,30 +66,24 @@ internal fun KotlinConfNavHost( popTransactionSpec: AnimatedContentTransitionScope>.() -> ContentTransform, ) { // TODO: make this saveable! - val backStack: SnapshotStateList = remember { - val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen - mutableStateListOf(startDestination) - } + val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen + val appBackStack= rememberBackstack(startDestination) - NotificationHandler(backStack) + NotificationHandler(appBackStack) //PlatformNavHandler(navController) NavDisplay( - backStack = backStack, + backStack = appBackStack.backStack, entryProvider = entryProvider { - screens(backStack) + screens(appBackStack) }, popTransitionSpec = popTransactionSpec, ) } -fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { - fun popBackStack() { - backStack.removeAt(backStack.lastIndex) - } - - startScreens(backStack) +fun EntryProviderBuilder.screens(backStack: BackStack) { + startScreens(backStack) // TODO inline these later entry { MainScreen(onNavigate = { backStack.add(it) }) @@ -104,7 +91,7 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { entry { SpeakerDetailScreen( speakerId = it.speakerId, - onBack = { popBackStack() }, + onBack = backStack::pop, onSession = { backStack.add(SessionScreen(it)) }, ) } @@ -113,7 +100,7 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { SessionScreen( sessionId = it.sessionId, openedForFeedback = it.openedForFeedback, - onBack = { popBackStack() }, + onBack = backStack::pop, onPrivacyNoticeNeeded = { backStack.add(AppPrivacyNoticePrompt) }, onSpeaker = { speakerId -> backStack.add(SpeakerDetailScreen(speakerId)) }, onWatchVideo = { videoUrl -> urlHandler.openUri(videoUrl) }, @@ -126,7 +113,7 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { entry { val uriHandler = LocalUriHandler.current AboutAppScreen( - onBack = { popBackStack() }, + onBack = backStack::pop, onGitHubRepo = { uriHandler.openUri(URLs.GITHUB_REPO) }, onRateApp = { getStoreUrl()?.let { uriHandler.openUri(it) } }, onPrivacyNotice = { backStack.add(AppPrivacyNoticeScreen) }, @@ -141,14 +128,14 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { onLicenseClick = { licenseName, licenseText -> backStack.add(SingleLicenseScreen(licenseName, licenseText)) }, - onBack = { popBackStack() }, + onBack = backStack::pop, ) } entry { SingleLicenseScreen( licenseName = it.licenseName, licenseContent = it.licenseText, - onBack = { popBackStack() }, + onBack = backStack::pop, ) } entry { @@ -157,35 +144,35 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { onPrivacyNotice = { backStack.add(VisitorPrivacyNoticeScreen) }, onGeneralTerms = { backStack.add(TermsOfUseScreen) }, onWebsiteLink = { urlHandler.openUri(URLs.KOTLINCONF_HOMEPAGE) }, - onBack = { popBackStack() }, + onBack = backStack::pop, onSpeaker = { speakerId -> backStack.add(SpeakerDetailScreen(speakerId)) }, ) } entry { - CodeOfConduct(onBack = { popBackStack() }) + CodeOfConduct(onBack = backStack::pop) } entry { - SettingsScreen(onBack = { popBackStack() }) + SettingsScreen(onBack = backStack::pop) } entry { - VisitorPrivacyNotice(onBack = { popBackStack() }) + VisitorPrivacyNotice(onBack = backStack::pop) } entry { AppPrivacyNotice( - onBack = { popBackStack() }, + onBack = backStack::pop, onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, ) } entry { VisitorTermsOfUse( - onBack = { popBackStack() }, + onBack = backStack::pop, onCodeOfConduct = { backStack.add(CodeOfConductScreen) }, onVisitorPrivacyNotice = { backStack.add(VisitorPrivacyNoticeScreen) }, ) } entry { AppTermsOfUse( - onBack = { popBackStack() }, + onBack = backStack::pop, onAppPrivacyNotice = { backStack.add(AppPrivacyNoticeScreen) }, @@ -193,7 +180,7 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { } entry { PartnersScreen( - onBack = { popBackStack() }, + onBack = backStack::pop, onPartnerDetail = { partnerId -> backStack.add(PartnerDetailScreen(partnerId)) } @@ -202,47 +189,45 @@ fun EntryProviderBuilder.screens(backStack: SnapshotStateList) { entry { PartnerDetailScreen( partnerId = it.partnerId, - onBack = { popBackStack() }, + onBack = backStack::pop, ) } entry { AppPrivacyNoticePrompt( - onRejectNotice = { popBackStack() }, - onAcceptNotice = { popBackStack() }, + onRejectNotice = backStack::pop, + onAcceptNotice = backStack::pop, onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, confirmationRequired = true, ) } entry { - DeveloperMenuScreen(onBack = { popBackStack() }) + DeveloperMenuScreen(onBack = backStack::pop) } entry { NestedMapScreen( roomName = it.roomName, - onBack = { popBackStack() }, + onBack = backStack::pop, ) } } -fun EntryProviderBuilder.startScreens(backStack: SnapshotStateList) { +fun EntryProviderBuilder.startScreens(backStack: BackStack) { entry { val skipNotifications = LocalFlags.current.supportsNotifications.not() AppPrivacyNoticePrompt( onRejectNotice = { if (skipNotifications) { - backStack.add(MainScreen) - backStack.removeAll { it !is MainScreen } + backStack.add(MainScreen, clearOthers = true) } else { backStack.add(StartNotificationsScreen) } }, onAcceptNotice = { if (skipNotifications) { - backStack.add(MainScreen) - backStack.removeAll { it !is MainScreen } + backStack.add(MainScreen, clearOthers = true) } else { backStack.add(StartNotificationsScreen) } @@ -255,8 +240,7 @@ fun EntryProviderBuilder.startScreens(backStack: SnapshotStateList) { entry { StartNotificationsScreen( onDone = { - backStack.add(MainScreen) - backStack.removeAll { it !is MainScreen } + backStack.add(MainScreen, clearOthers = true) } ) } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index d2094496..6f335718 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -17,10 +17,7 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.BackHandler @@ -70,6 +67,7 @@ import org.jetbrains.kotlinconf.generated.resources.team_28_fill import org.jetbrains.kotlinconf.navigation.AboutAppScreen import org.jetbrains.kotlinconf.navigation.AboutConferenceScreen import org.jetbrains.kotlinconf.navigation.AppPrivacyNoticePrompt +import org.jetbrains.kotlinconf.navigation.BackStack import org.jetbrains.kotlinconf.navigation.CodeOfConductScreen import org.jetbrains.kotlinconf.navigation.InfoScreen import org.jetbrains.kotlinconf.navigation.MainScreenMarker @@ -81,6 +79,7 @@ import org.jetbrains.kotlinconf.navigation.SessionScreen import org.jetbrains.kotlinconf.navigation.SettingsScreen import org.jetbrains.kotlinconf.navigation.SpeakerDetailScreen import org.jetbrains.kotlinconf.navigation.SpeakersScreen +import org.jetbrains.kotlinconf.navigation.rememberBackstack import org.jetbrains.kotlinconf.ui.components.Divider import org.jetbrains.kotlinconf.ui.components.MainNavDestination import org.jetbrains.kotlinconf.ui.components.MainNavigation @@ -104,9 +103,9 @@ fun MainScreen( .background(color = KotlinConfTheme.colors.mainBackground) .windowInsetsPadding(WindowInsets.safeDrawing) ) { - val localBackStack = remember { mutableStateListOf(ScheduleScreen) } + val localBackStack = rememberBackstack(ScheduleScreen) NavDisplay( - backStack = localBackStack, + backStack = localBackStack.backStack, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -160,6 +159,7 @@ fun MainScreen( @Composable private fun MainBackHandler() { + // TODO try simplifying this once Nav3 runs on iOS too if (!LocalFlags.current.enableBackOnMainScreens) { // Prevent back navigation with an empty handler @OptIn(ExperimentalComposeUiApi::class) @@ -174,7 +174,7 @@ private fun isKeyboardOpen(): Boolean { } @Composable -private fun BottomNavigation(localBackStack: SnapshotStateList) { +private fun BottomNavigation(localBackStack: BackStack) { val bottomNavDestinations: List> = listOf( MainNavDestination( @@ -207,7 +207,8 @@ private fun BottomNavigation(localBackStack: SnapshotStateList ), ) - val currentDestination = localBackStack.last() + // TODO check if we can simplify this + val currentDestination = localBackStack.backStack.last() val currentBottomNavDestination = bottomNavDestinations.find { dest -> currentDestination == dest.route } @@ -217,16 +218,18 @@ private fun BottomNavigation(localBackStack: SnapshotStateList currentDestination = currentBottomNavDestination, destinations = bottomNavDestinations, onSelect = { - val target = it.route - if (localBackStack.last() == target) { - return@MainNavigation - } + localBackStack.edit { + val target = it.route + if (last() == target) { + return@edit + } - localBackStack.add(target) + add(target) - if (localBackStack.size > 2) { - // Remove everything but the first and last entry - localBackStack.removeRange(1, localBackStack.lastIndex) + if (size > 2) { + // Remove everything but the first and last entry + subList(1, lastIndex).clear() + } } }, ) From ad099a0a11ad7908cd59e9e407a3f30e6544206f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 16:14:11 +0200 Subject: [PATCH 08/28] Try to use dev build for KMP Nav3 --- gradle/libs.versions.toml | 7 ++- shared/build.gradle.kts | 69 +++++++++++++------------- ui-components-gallery/build.gradle.kts | 36 +++++++------- ui-components/build.gradle.kts | 26 +++++----- 4 files changed, 68 insertions(+), 70 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0f9af1d..79bd9cb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,8 +32,7 @@ markdown = "0.37.0" multiplatform-settings = "1.3.0" postgresql = "42.7.7" slf4jNop = "2.0.17" -nav3Core = "1.0.0-alpha10" -lifecycleViewmodelNav3 = "2.10.0-alpha04" +nav3Core = "1.0.0+dev3015" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } @@ -42,9 +41,9 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } -androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +#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-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 1443354e..6a9db4dc 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,39 +27,39 @@ kotlin { } } -// jvm() -// -// @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) -// wasmJs { -// binaries.executable() -// browser { -// val projectDir = project.projectDir.path -// val rootDir = project.rootDir.path -// commonWebpackConfig { -// outputFileName = "kotlinconf-app.js" -// } -// } -// } -// -// js { -// binaries.executable() -// browser { -// commonWebpackConfig { -// outputFileName = "kotlinconf-app.js" -// } -// } -// } -// -// listOf( -// iosX64(), -// iosArm64(), -// iosSimulatorArm64() -// ).forEach { -// it.binaries.framework { -// baseName = "shared" -// isStatic = true -// } -// } + jvm() + + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + binaries.executable() + browser { + val projectDir = project.projectDir.path + val rootDir = project.rootDir.path + commonWebpackConfig { + outputFileName = "kotlinconf-app.js" + } + } + } + + js { + binaries.executable() + browser { + commonWebpackConfig { + outputFileName = "kotlinconf-app.js" + } + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "shared" + isStatic = true + } + } applyDefaultHierarchyTemplate() @@ -83,9 +83,8 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) +// implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.compose.ui.backhandler) implementation(libs.ktor.client.core) diff --git a/ui-components-gallery/build.gradle.kts b/ui-components-gallery/build.gradle.kts index 38f20383..f02d446f 100644 --- a/ui-components-gallery/build.gradle.kts +++ b/ui-components-gallery/build.gradle.kts @@ -10,24 +10,24 @@ plugins { kotlin { androidTarget() -// jvm() -// -// iosX64() -// iosArm64() -// iosSimulatorArm64() -// -// @OptIn(ExperimentalWasmDsl::class) -// wasmJs { -// binaries.executable() -// browser { -// commonWebpackConfig { -// outputFileName = "kotlinconf-app-gallery.js" -// } -// } -// } -// js { -// browser() -// } + jvm() + + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + binaries.executable() + browser { + commonWebpackConfig { + outputFileName = "kotlinconf-app-gallery.js" + } + } + } + js { + browser() + } sourceSets { commonMain.dependencies { diff --git a/ui-components/build.gradle.kts b/ui-components/build.gradle.kts index b9bd26f8..d37159a9 100644 --- a/ui-components/build.gradle.kts +++ b/ui-components/build.gradle.kts @@ -15,19 +15,19 @@ kotlin { androidTarget() -// jvm() -// -// iosX64() -// iosArm64() -// iosSimulatorArm64() -// -// @OptIn(ExperimentalWasmDsl::class) -// wasmJs { -// browser() -// } -// js { -// browser() -// } + jvm() + + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + js { + browser() + } sourceSets { commonMain.dependencies { From 75a84963a23c85f7de9bc180e186acdda9795669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 16:24:25 +0200 Subject: [PATCH 09/28] Use correct KMP artifact --- gradle/libs.versions.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79bd9cb6..ed3a47ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,9 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } #androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } -androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +#androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } + #androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } From 2d41719b05c7f41e7017271e1965a82d56de1ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 16:41:29 +0200 Subject: [PATCH 10/28] Fix animation param, add TODOs --- .../src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt | 6 +----- .../jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt | 7 ++++--- .../org/jetbrains/kotlinconf/screens/LicenseScreens.kt | 1 + .../kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt | 1 + .../org/jetbrains/kotlinconf/screens/ScheduleScreen.kt | 1 + .../org/jetbrains/kotlinconf/screens/SpeakersScreen.kt | 1 + 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt index 5dc32afd..09b53abc 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt @@ -2,8 +2,6 @@ package org.jetbrains.kotlinconf import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -14,17 +12,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavBackStackEntry import androidx.navigation3.scene.Scene import org.jetbrains.kotlinconf.navigation.KotlinConfNavHost import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme import org.koin.compose.koinInject -import kotlin.jvm.JvmSuppressWildcards @Composable fun App( onThemeChange: ((isDarkTheme: Boolean) -> Unit)? = null, - popTransactionSpec: AnimatedContentTransitionScope>.() -> ContentTransform + popTransactionSpec: (AnimatedContentTransitionScope>.() -> ContentTransform)? = null, ) { val service = koinInject() val currentTheme by service.getTheme().collectAsStateWithLifecycle(initialValue = Theme.SYSTEM) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index aabfcb29..2b669b79 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -9,6 +9,7 @@ import androidx.navigation3.runtime.EntryProviderBuilder import androidx.navigation3.runtime.entryProvider import androidx.navigation3.scene.Scene import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.ui.defaultPopTransitionSpec import kotlinx.coroutines.channels.Channel import org.jetbrains.kotlinconf.LocalFlags import org.jetbrains.kotlinconf.LocalNotificationId @@ -63,11 +64,11 @@ private fun NotificationHandler(backStack: BackStack) { @Composable internal fun KotlinConfNavHost( isOnboardingComplete: Boolean, - popTransactionSpec: AnimatedContentTransitionScope>.() -> ContentTransform, + popTransactionSpec: (AnimatedContentTransitionScope>.() -> ContentTransform)?, ) { // TODO: make this saveable! val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen - val appBackStack= rememberBackstack(startDestination) + val appBackStack: BackStack = rememberBackstack(startDestination) NotificationHandler(appBackStack) //PlatformNavHandler(navController) @@ -77,7 +78,7 @@ internal fun KotlinConfNavHost( entryProvider = entryProvider { screens(appBackStack) }, - popTransitionSpec = popTransactionSpec, + popTransitionSpec = popTransactionSpec ?: defaultPopTransitionSpec(), ) } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt index 8c5c46fa..38071d51 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt @@ -124,6 +124,7 @@ fun LicensesScreen( ) }, searchContent = { + // TODO update to new APIs @OptIn(ExperimentalComposeUiApi::class) BackHandler(true) { searchState = MainHeaderContainerState.Title diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 6f335718..1153ed0e 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -103,6 +103,7 @@ fun MainScreen( .background(color = KotlinConfTheme.colors.mainBackground) .windowInsetsPadding(WindowInsets.safeDrawing) ) { + // TODO: make this saveable! val localBackStack = rememberBackstack(ScheduleScreen) NavDisplay( backStack = localBackStack.backStack, diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt index 350cfc9a..5be893a4 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt @@ -367,6 +367,7 @@ private fun Header( ) }, searchContent = { + // TODO update to new APIs @OptIn(ExperimentalComposeUiApi::class) BackHandler(true) { onHeaderStateChange(MainHeaderContainerState.Title) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt index 65527c64..e7e23b38 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt @@ -90,6 +90,7 @@ fun SpeakersScreen( ) }, searchContent = { + // TODO update to new APIs @OptIn(ExperimentalComposeUiApi::class) BackHandler(true) { searchState = MainHeaderContainerState.Title From 779d9ea8d223efe7bb4e042793384515c89dd88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Thu, 2 Oct 2025 16:59:53 +0200 Subject: [PATCH 11/28] Add entry decorators --- .../jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt | 7 +++++++ .../kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 2b669b79..a611c6fc 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalUriHandler import androidx.navigation3.runtime.EntryProviderBuilder import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.defaultPopTransitionSpec import kotlinx.coroutines.channels.Channel @@ -78,6 +80,11 @@ internal fun KotlinConfNavHost( entryProvider = entryProvider { screens(appBackStack) }, + entryDecorators = listOf( + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + // rememberViewModelStoreNavEntryDecorator(), // TODO + ), popTransitionSpec = popTransactionSpec ?: defaultPopTransitionSpec(), ) } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 1153ed0e..0b0a4b01 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.NavDisplay import kotlinconfapp.shared.generated.resources.Res import kotlinconfapp.shared.generated.resources.clock_28 @@ -113,6 +115,11 @@ fun MainScreen( transitionSpec = { NoContentTransition }, popTransitionSpec = { NoContentTransition }, predictivePopTransitionSpec = { NoContentTransition }, + entryDecorators = listOf( + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + // rememberViewModelStoreNavEntryDecorator(), // TODO + ), entryProvider = entryProvider { entry { MainBackHandler() From fc4b60fc5f024581c64fc3e72e4f2e02166b850c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Mon, 20 Oct 2025 16:53:04 +0200 Subject: [PATCH 12/28] Use lifecycle-viewmodel-navigation3, update to latest dev build --- gradle/libs.versions.toml | 7 ++----- shared/build.gradle.kts | 2 +- .../kotlinconf/navigation/KotlinConfNavHost.kt | 15 +++++++-------- .../jetbrains/kotlinconf/screens/MainScreen.kt | 9 ++++----- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed3a47ef..d87355a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ markdown = "0.37.0" multiplatform-settings = "1.3.0" postgresql = "42.7.7" slf4jNop = "2.0.17" -nav3Core = "1.0.0+dev3015" +nav3Core = "1.0.0+dev3105" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } @@ -41,11 +41,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } -#androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } -#androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } - -#androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } +androidx-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6a9db4dc..d16a5101 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -84,7 +84,7 @@ kotlin { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation3.ui) -// implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.compose.ui.backhandler) implementation(libs.ktor.client.core) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index a611c6fc..34890b14 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -5,11 +5,11 @@ import androidx.compose.animation.ContentTransform import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalUriHandler -import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.scene.Scene -import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.defaultPopTransitionSpec import kotlinx.coroutines.channels.Channel @@ -81,16 +81,15 @@ internal fun KotlinConfNavHost( screens(appBackStack) }, entryDecorators = listOf( - rememberSceneSetupNavEntryDecorator(), - rememberSavedStateNavEntryDecorator(), - // rememberViewModelStoreNavEntryDecorator(), // TODO + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), ), popTransitionSpec = popTransactionSpec ?: defaultPopTransitionSpec(), ) } -fun EntryProviderBuilder.screens(backStack: BackStack) { +fun EntryProviderScope.screens(backStack: BackStack) { startScreens(backStack) // TODO inline these later entry { @@ -222,7 +221,7 @@ fun EntryProviderBuilder.screens(backStack: BackStack) { } } -fun EntryProviderBuilder.startScreens(backStack: BackStack) { +fun EntryProviderScope.startScreens(backStack: BackStack) { entry { val skipNotifications = LocalFlags.current.supportsNotifications.not() AppPrivacyNoticePrompt( diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 0b0a4b01..c1f301b0 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -24,9 +24,9 @@ import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator -import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import kotlinconfapp.shared.generated.resources.Res import kotlinconfapp.shared.generated.resources.clock_28 @@ -116,9 +116,8 @@ fun MainScreen( popTransitionSpec = { NoContentTransition }, predictivePopTransitionSpec = { NoContentTransition }, entryDecorators = listOf( - rememberSceneSetupNavEntryDecorator(), - rememberSavedStateNavEntryDecorator(), - // rememberViewModelStoreNavEntryDecorator(), // TODO + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), ), entryProvider = entryProvider { entry { From be38f3fe15bec550c75d139fea156f00dfd79d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Mon, 27 Oct 2025 17:20:27 +0100 Subject: [PATCH 13/28] Use NavBackStack instead of a custom BackStack class --- .../navigation/{Routes.kt => AppRoute.kt} | 63 +++++++------------ .../kotlinconf/navigation/BackStack.kt | 30 +++------ .../navigation/KotlinConfNavHost.kt | 62 +++++++++--------- .../kotlinconf/navigation/MainRoute.kt | 23 +++++++ .../kotlinconf/screens/MainScreen.kt | 24 +++---- 5 files changed, 101 insertions(+), 101 deletions(-) rename shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/{Routes.kt => AppRoute.kt} (57%) create mode 100644 shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/AppRoute.kt similarity index 57% rename from shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt rename to shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/AppRoute.kt index a7b9e548..d9de38c8 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/Routes.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/AppRoute.kt @@ -1,112 +1,97 @@ package org.jetbrains.kotlinconf.navigation +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.jetbrains.kotlinconf.PartnerId import org.jetbrains.kotlinconf.SessionId import org.jetbrains.kotlinconf.SpeakerId +@Serializable +sealed interface AppRoute : NavKey + @Serializable @SerialName("AboutConference") -data object AboutConferenceScreen +data object AboutConferenceScreen : AppRoute @Serializable @SerialName("CodeOfConduct") -data object CodeOfConductScreen +data object CodeOfConductScreen : AppRoute @Serializable @SerialName("AboutApp") -data object AboutAppScreen - -@Serializable -@SerialName("Info") -data object InfoScreen: MainScreenMarker +data object AboutAppScreen : AppRoute @Serializable @SerialName("WelcomePrivacyNotice") -data object StartPrivacyNoticeScreen +data object StartPrivacyNoticeScreen : AppRoute @Serializable @SerialName("WelcomeSetupNotifications") -data object StartNotificationsScreen +data object StartNotificationsScreen : AppRoute @Serializable @SerialName("AppPrivacyNoticePrompt") -data object AppPrivacyNoticePrompt +data object AppPrivacyNoticePrompt : AppRoute @Serializable @SerialName("Settings") -data object SettingsScreen +data object SettingsScreen : AppRoute @Serializable @SerialName("VisitorPrivacyNotice") -data object VisitorPrivacyNoticeScreen +data object VisitorPrivacyNoticeScreen : AppRoute @Serializable @SerialName("AppPrivacyNotice") -data object AppPrivacyNoticeScreen +data object AppPrivacyNoticeScreen : AppRoute @Serializable @SerialName("TermsOfUse") -data object TermsOfUseScreen +data object TermsOfUseScreen : AppRoute @Serializable @SerialName("AppTermsOfUse") -data object AppTermsOfUseScreen +data object AppTermsOfUseScreen : AppRoute @Serializable @SerialName("Licenses") -data object LicensesScreen +data object LicensesScreen : AppRoute @Serializable @SerialName("License") data class SingleLicenseScreen( val licenseName: String, val licenseText: String, -) +) : AppRoute @Serializable @SerialName("Partners") -data object PartnersScreen +data object PartnersScreen : AppRoute @Serializable @SerialName("Partner") -data class PartnerDetailScreen(val partnerId: PartnerId) +data class PartnerDetailScreen(val partnerId: PartnerId) : AppRoute @Serializable @SerialName("Main") -data object MainScreen - -sealed interface MainScreenMarker - -@Serializable -@SerialName("Schedule") -data object ScheduleScreen : MainScreenMarker +data object MainScreen : AppRoute @Serializable @SerialName("Session") data class SessionScreen( val sessionId: SessionId, val openedForFeedback: Boolean = false, -) - -@Serializable -@SerialName("Speakers") -data object SpeakersScreen: MainScreenMarker +) : AppRoute @Serializable @SerialName("Speaker") -data class SpeakerDetailScreen(val speakerId: SpeakerId) - -@Serializable -@SerialName("Map") -data object MapScreen: MainScreenMarker +data class SpeakerDetailScreen(val speakerId: SpeakerId) : AppRoute @Serializable @SerialName("MapDetail") -data class NestedMapScreen(val roomName: String) - +data class NestedMapScreen(val roomName: String) : AppRoute @Serializable @SerialName("DeveloperMenu") -data object DeveloperMenuScreen +data object DeveloperMenuScreen : AppRoute diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt index fb95ba66..4e110a9d 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt @@ -1,29 +1,15 @@ package org.jetbrains.kotlinconf.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.serialization.NavBackStackSerializer +import kotlinx.serialization.serializer @Composable -fun rememberBackstack(initial: T): BackStack = remember { BackStack(initial) } - -class BackStack(initial: T) { - private val _backStack = mutableStateListOf(initial) - val backStack: List get() = _backStack - - fun edit(actions: MutableList.() -> Unit) { - _backStack.actions() - } - - fun add(element: T, clearOthers: Boolean = false) { - _backStack.add(element) - - if (clearOthers) { - _backStack.removeRange(0, _backStack.lastIndex) - } - } - - fun pop() { - _backStack.removeAt(_backStack.lastIndex) +inline fun rememberNavBackStack(vararg elements: T): NavBackStack { + return rememberSerializable(serializer = NavBackStackSerializer(serializer())) { + NavBackStack(*elements) } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 34890b14..e28ec5fa 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.scene.Scene @@ -54,11 +55,11 @@ fun navigateToSession(sessionId: SessionId) { private val notificationNavRequests = Channel(capacity = 1) @Composable -private fun NotificationHandler(backStack: BackStack) { +private fun NotificationHandler(backStack: NavBackStack) { LaunchedEffect(Unit) { while (true) { - val destination = notificationNavRequests.receive() - backStack.add(destination) + val destination: Any = notificationNavRequests.receive() + backStack.add(destination as AppRoute) } } } @@ -70,13 +71,13 @@ internal fun KotlinConfNavHost( ) { // TODO: make this saveable! val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen - val appBackStack: BackStack = rememberBackstack(startDestination) + val appBackStack = rememberNavBackStack(startDestination) NotificationHandler(appBackStack) //PlatformNavHandler(navController) NavDisplay( - backStack = appBackStack.backStack, + backStack = appBackStack, entryProvider = entryProvider { screens(appBackStack) }, @@ -88,8 +89,7 @@ internal fun KotlinConfNavHost( ) } - -fun EntryProviderScope.screens(backStack: BackStack) { +fun EntryProviderScope.screens(backStack: NavBackStack) { startScreens(backStack) // TODO inline these later entry { @@ -98,7 +98,7 @@ fun EntryProviderScope.screens(backStack: BackStack) { entry { SpeakerDetailScreen( speakerId = it.speakerId, - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onSession = { backStack.add(SessionScreen(it)) }, ) } @@ -107,7 +107,7 @@ fun EntryProviderScope.screens(backStack: BackStack) { SessionScreen( sessionId = it.sessionId, openedForFeedback = it.openedForFeedback, - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onPrivacyNoticeNeeded = { backStack.add(AppPrivacyNoticePrompt) }, onSpeaker = { speakerId -> backStack.add(SpeakerDetailScreen(speakerId)) }, onWatchVideo = { videoUrl -> urlHandler.openUri(videoUrl) }, @@ -120,7 +120,7 @@ fun EntryProviderScope.screens(backStack: BackStack) { entry { val uriHandler = LocalUriHandler.current AboutAppScreen( - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onGitHubRepo = { uriHandler.openUri(URLs.GITHUB_REPO) }, onRateApp = { getStoreUrl()?.let { uriHandler.openUri(it) } }, onPrivacyNotice = { backStack.add(AppPrivacyNoticeScreen) }, @@ -135,14 +135,14 @@ fun EntryProviderScope.screens(backStack: BackStack) { onLicenseClick = { licenseName, licenseText -> backStack.add(SingleLicenseScreen(licenseName, licenseText)) }, - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, ) } entry { SingleLicenseScreen( licenseName = it.licenseName, licenseContent = it.licenseText, - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, ) } entry { @@ -151,35 +151,35 @@ fun EntryProviderScope.screens(backStack: BackStack) { onPrivacyNotice = { backStack.add(VisitorPrivacyNoticeScreen) }, onGeneralTerms = { backStack.add(TermsOfUseScreen) }, onWebsiteLink = { urlHandler.openUri(URLs.KOTLINCONF_HOMEPAGE) }, - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onSpeaker = { speakerId -> backStack.add(SpeakerDetailScreen(speakerId)) }, ) } entry { - CodeOfConduct(onBack = backStack::pop) + CodeOfConduct(onBack = backStack::removeLastOrNull) } entry { - SettingsScreen(onBack = backStack::pop) + SettingsScreen(onBack = backStack::removeLastOrNull) } entry { - VisitorPrivacyNotice(onBack = backStack::pop) + VisitorPrivacyNotice(onBack = backStack::removeLastOrNull) } entry { AppPrivacyNotice( - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, ) } entry { VisitorTermsOfUse( - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onCodeOfConduct = { backStack.add(CodeOfConductScreen) }, onVisitorPrivacyNotice = { backStack.add(VisitorPrivacyNoticeScreen) }, ) } entry { AppTermsOfUse( - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onAppPrivacyNotice = { backStack.add(AppPrivacyNoticeScreen) }, @@ -187,7 +187,7 @@ fun EntryProviderScope.screens(backStack: BackStack) { } entry { PartnersScreen( - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, onPartnerDetail = { partnerId -> backStack.add(PartnerDetailScreen(partnerId)) } @@ -196,45 +196,49 @@ fun EntryProviderScope.screens(backStack: BackStack) { entry { PartnerDetailScreen( partnerId = it.partnerId, - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, ) } entry { AppPrivacyNoticePrompt( - onRejectNotice = backStack::pop, - onAcceptNotice = backStack::pop, + onRejectNotice = backStack::removeLastOrNull, + onAcceptNotice = backStack::removeLastOrNull, onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, confirmationRequired = true, ) } entry { - DeveloperMenuScreen(onBack = backStack::pop) + DeveloperMenuScreen(onBack = backStack::removeLastOrNull) } entry { NestedMapScreen( roomName = it.roomName, - onBack = backStack::pop, + onBack = backStack::removeLastOrNull, ) } } -fun EntryProviderScope.startScreens(backStack: BackStack) { +fun EntryProviderScope.startScreens(backStack: NavBackStack) { entry { val skipNotifications = LocalFlags.current.supportsNotifications.not() AppPrivacyNoticePrompt( onRejectNotice = { if (skipNotifications) { - backStack.add(MainScreen, clearOthers = true) + // TODO do this in a safer way to avoid a potentially empty stack for a moment + backStack.clear() + backStack.add(MainScreen) } else { backStack.add(StartNotificationsScreen) } }, onAcceptNotice = { if (skipNotifications) { - backStack.add(MainScreen, clearOthers = true) + // TODO do this in a safer way to avoid a potentially empty stack for a moment + backStack.clear() + backStack.add(MainScreen) } else { backStack.add(StartNotificationsScreen) } @@ -247,7 +251,7 @@ fun EntryProviderScope.startScreens(backStack: BackStack) { entry { StartNotificationsScreen( onDone = { - backStack.add(MainScreen, clearOthers = true) + backStack.add(MainScreen) } ) } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt new file mode 100644 index 00000000..1cfddca5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt @@ -0,0 +1,23 @@ +package org.jetbrains.kotlinconf.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface MainRoute : NavKey + +@Serializable +data object ScheduleScreen : MainRoute + +@Serializable +@SerialName("Speakers") +data object SpeakersScreen : MainRoute + +@Serializable +@SerialName("Map") +data object MapScreen : MainRoute + +@Serializable +@SerialName("Info") +data object InfoScreen : MainRoute diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index c1f301b0..2d700541 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay @@ -69,10 +70,10 @@ import org.jetbrains.kotlinconf.generated.resources.team_28_fill import org.jetbrains.kotlinconf.navigation.AboutAppScreen import org.jetbrains.kotlinconf.navigation.AboutConferenceScreen import org.jetbrains.kotlinconf.navigation.AppPrivacyNoticePrompt -import org.jetbrains.kotlinconf.navigation.BackStack +import org.jetbrains.kotlinconf.navigation.AppRoute import org.jetbrains.kotlinconf.navigation.CodeOfConductScreen import org.jetbrains.kotlinconf.navigation.InfoScreen -import org.jetbrains.kotlinconf.navigation.MainScreenMarker +import org.jetbrains.kotlinconf.navigation.MainRoute import org.jetbrains.kotlinconf.navigation.MapScreen import org.jetbrains.kotlinconf.navigation.NewsListScreen import org.jetbrains.kotlinconf.navigation.PartnersScreen @@ -81,7 +82,7 @@ import org.jetbrains.kotlinconf.navigation.SessionScreen import org.jetbrains.kotlinconf.navigation.SettingsScreen import org.jetbrains.kotlinconf.navigation.SpeakerDetailScreen import org.jetbrains.kotlinconf.navigation.SpeakersScreen -import org.jetbrains.kotlinconf.navigation.rememberBackstack +import org.jetbrains.kotlinconf.navigation.rememberNavBackStack import org.jetbrains.kotlinconf.ui.components.Divider import org.jetbrains.kotlinconf.ui.components.MainNavDestination import org.jetbrains.kotlinconf.ui.components.MainNavigation @@ -92,7 +93,7 @@ private val NoContentTransition = ContentTransform(EnterTransition.None, ExitTra @Composable fun MainScreen( - onNavigate: (Any) -> Unit, + onNavigate: (AppRoute) -> Unit, service: ConferenceService = koinInject(), ) { LaunchedEffect(Unit) { @@ -106,9 +107,10 @@ fun MainScreen( .windowInsetsPadding(WindowInsets.safeDrawing) ) { // TODO: make this saveable! - val localBackStack = rememberBackstack(ScheduleScreen) + val localBackStack = rememberNavBackStack(ScheduleScreen) + NavDisplay( - backStack = localBackStack.backStack, + backStack = localBackStack, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -181,8 +183,8 @@ private fun isKeyboardOpen(): Boolean { } @Composable -private fun BottomNavigation(localBackStack: BackStack) { - val bottomNavDestinations: List> = +private fun BottomNavigation(localBackStack: NavBackStack) { + val bottomNavDestinations: List> = listOf( MainNavDestination( label = stringResource(Res.string.nav_destination_schedule), @@ -215,7 +217,7 @@ private fun BottomNavigation(localBackStack: BackStack) { ) // TODO check if we can simplify this - val currentDestination = localBackStack.backStack.last() + val currentDestination = localBackStack.last() val currentBottomNavDestination = bottomNavDestinations.find { dest -> currentDestination == dest.route } @@ -225,10 +227,10 @@ private fun BottomNavigation(localBackStack: BackStack) { currentDestination = currentBottomNavDestination, destinations = bottomNavDestinations, onSelect = { - localBackStack.edit { + localBackStack.apply { val target = it.route if (last() == target) { - return@edit + return@apply } add(target) From 8aade02be6313d9137844a5feae6813efcd1d0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Fri, 31 Oct 2025 16:37:46 +0000 Subject: [PATCH 14/28] Simplify setup --- .../org/jetbrains/kotlinconf/navigation/BackStack.kt | 10 ++++------ .../kotlinconf/navigation/KotlinConfNavHost.kt | 8 +++----- .../org/jetbrains/kotlinconf/screens/MainScreen.kt | 4 +--- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt index 4e110a9d..e83b601c 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt @@ -1,15 +1,13 @@ package org.jetbrains.kotlinconf.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.saveable.rememberSerializable -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.serialization.NavBackStackSerializer +import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer import kotlinx.serialization.serializer @Composable -inline fun rememberNavBackStack(vararg elements: T): NavBackStack { - return rememberSerializable(serializer = NavBackStackSerializer(serializer())) { - NavBackStack(*elements) - } +inline fun rememberNavBackStack(vararg elements: T): MutableList { + return rememberSerializable(serializer = SnapshotStateListSerializer(serializer())) { mutableStateListOf(*elements) } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index e28ec5fa..9428b439 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.scene.Scene @@ -55,7 +54,7 @@ fun navigateToSession(sessionId: SessionId) { private val notificationNavRequests = Channel(capacity = 1) @Composable -private fun NotificationHandler(backStack: NavBackStack) { +private fun NotificationHandler(backStack: MutableList) { LaunchedEffect(Unit) { while (true) { val destination: Any = notificationNavRequests.receive() @@ -69,7 +68,6 @@ internal fun KotlinConfNavHost( isOnboardingComplete: Boolean, popTransactionSpec: (AnimatedContentTransitionScope>.() -> ContentTransform)?, ) { - // TODO: make this saveable! val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen val appBackStack = rememberNavBackStack(startDestination) @@ -89,7 +87,7 @@ internal fun KotlinConfNavHost( ) } -fun EntryProviderScope.screens(backStack: NavBackStack) { +fun EntryProviderScope.screens(backStack: MutableList) { startScreens(backStack) // TODO inline these later entry { @@ -221,7 +219,7 @@ fun EntryProviderScope.screens(backStack: NavBackStack) { } } -fun EntryProviderScope.startScreens(backStack: NavBackStack) { +fun EntryProviderScope.startScreens(backStack: MutableList) { entry { val skipNotifications = LocalFlags.current.supportsNotifications.not() AppPrivacyNoticePrompt( diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 2d700541..a5d9036c 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay @@ -106,7 +105,6 @@ fun MainScreen( .background(color = KotlinConfTheme.colors.mainBackground) .windowInsetsPadding(WindowInsets.safeDrawing) ) { - // TODO: make this saveable! val localBackStack = rememberNavBackStack(ScheduleScreen) NavDisplay( @@ -183,7 +181,7 @@ private fun isKeyboardOpen(): Boolean { } @Composable -private fun BottomNavigation(localBackStack: NavBackStack) { +private fun BottomNavigation(localBackStack: MutableList) { val bottomNavDestinations: List> = listOf( MainNavDestination( From 4e801202f3e0824182e9c6ba832d7058fcdfa90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Fri, 31 Oct 2025 17:02:55 +0000 Subject: [PATCH 15/28] Fix start screen handling --- .../org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 9428b439..2cdc61a9 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -225,7 +225,6 @@ fun EntryProviderScope.startScreens(backStack: MutableList) { AppPrivacyNoticePrompt( onRejectNotice = { if (skipNotifications) { - // TODO do this in a safer way to avoid a potentially empty stack for a moment backStack.clear() backStack.add(MainScreen) } else { @@ -234,7 +233,6 @@ fun EntryProviderScope.startScreens(backStack: MutableList) { }, onAcceptNotice = { if (skipNotifications) { - // TODO do this in a safer way to avoid a potentially empty stack for a moment backStack.clear() backStack.add(MainScreen) } else { @@ -249,6 +247,7 @@ fun EntryProviderScope.startScreens(backStack: MutableList) { entry { StartNotificationsScreen( onDone = { + backStack.clear() backStack.add(MainScreen) } ) From fd27c0bd14f2bb4dd1a67b76e32ad20ea16db8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Mon, 3 Nov 2025 13:51:13 +0100 Subject: [PATCH 16/28] Fix after rebase --- .../kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index a5d9036c..1637893f 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -74,7 +74,6 @@ import org.jetbrains.kotlinconf.navigation.CodeOfConductScreen import org.jetbrains.kotlinconf.navigation.InfoScreen import org.jetbrains.kotlinconf.navigation.MainRoute import org.jetbrains.kotlinconf.navigation.MapScreen -import org.jetbrains.kotlinconf.navigation.NewsListScreen import org.jetbrains.kotlinconf.navigation.PartnersScreen import org.jetbrains.kotlinconf.navigation.ScheduleScreen import org.jetbrains.kotlinconf.navigation.SessionScreen @@ -126,7 +125,6 @@ fun MainScreen( InfoScreen( onAboutConf = { onNavigate(AboutConferenceScreen) }, onAboutApp = { onNavigate(AboutAppScreen) }, - onNewsFeed = { onNavigate(NewsListScreen) }, onOurPartners = { onNavigate(PartnersScreen) }, onCodeOfConduct = { onNavigate(CodeOfConductScreen) }, onTwitter = { uriHandler.openUri(URLs.TWITTER) }, From 91a97cda9d0dc7ea40dccc333d982934113da3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 5 Nov 2025 08:58:50 +0100 Subject: [PATCH 17/28] Replace BackHandler with NavigationBackhandler --- shared/build.gradle.kts | 1 - .../kotlinconf/screens/LicenseScreens.kt | 28 +++++++++++++----- .../kotlinconf/screens/MainScreen.kt | 15 ++++++---- .../kotlinconf/screens/ScheduleScreen.kt | 29 ++++++++++++++----- .../kotlinconf/screens/SpeakersScreen.kt | 26 ++++++++++++----- 5 files changed, 69 insertions(+), 30 deletions(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d16a5101..28daa4f0 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -85,7 +85,6 @@ kotlin { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.lifecycle.viewmodel.navigation3) - implementation(libs.compose.ui.backhandler) implementation(libs.ktor.client.core) implementation(libs.aboutlibraries.core) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt index 38071d51..b11c0630 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt @@ -22,9 +22,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion @@ -34,7 +32,18 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.mikepenz.aboutlibraries.entity.Library +import kotlinconfapp.shared.generated.resources.Res +import kotlinconfapp.shared.generated.resources.licenses_number_of_results +import kotlinconfapp.shared.generated.resources.licenses_title +import kotlinconfapp.ui_components.generated.resources.UiRes +import kotlinconfapp.ui_components.generated.resources.arrow_left_24 +import kotlinconfapp.ui_components.generated.resources.main_header_back +import kotlinconfapp.ui_components.generated.resources.main_header_search_hint +import kotlinconfapp.ui_components.generated.resources.search_24 import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.kotlinconf.HideKeyboardOnDragHandler @@ -124,12 +133,15 @@ fun LicensesScreen( ) }, searchContent = { - // TODO update to new APIs - @OptIn(ExperimentalComposeUiApi::class) - BackHandler(true) { - searchState = MainHeaderContainerState.Title - searchText = "" - } + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = true, + onBackCompleted = { + searchState = MainHeaderContainerState.Title + searchText = "" + }, + ) + MainHeaderSearchBar( searchValue = searchText, onSearchValueChange = { searchText = it }, diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 1637893f..3c53b86e 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -18,9 +18,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp @@ -28,6 +26,9 @@ import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDe import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import kotlinconfapp.shared.generated.resources.Res import kotlinconfapp.shared.generated.resources.clock_28 import kotlinconfapp.shared.generated.resources.clock_28_fill @@ -164,11 +165,13 @@ fun MainScreen( @Composable private fun MainBackHandler() { - // TODO try simplifying this once Nav3 runs on iOS too if (!LocalFlags.current.enableBackOnMainScreens) { - // Prevent back navigation with an empty handler - @OptIn(ExperimentalComposeUiApi::class) - BackHandler(true) { } + // Prevent back navigation + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = true, + onBackCompleted = { /* Do nothing */ }, + ) } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt index 5be893a4..f2a70264 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.semantics.CollectionInfo import androidx.compose.ui.semantics.CollectionItemInfo @@ -45,6 +44,19 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlinconfapp.shared.generated.resources.Res +import kotlinconfapp.shared.generated.resources.nav_destination_schedule +import kotlinconfapp.shared.generated.resources.schedule_action_filter_bookmarked +import kotlinconfapp.shared.generated.resources.schedule_action_search +import kotlinconfapp.shared.generated.resources.schedule_error_no_data +import kotlinconfapp.shared.generated.resources.schedule_in_x_minutes +import kotlinconfapp.shared.generated.resources.schedule_label_no_bookmarks +import kotlinconfapp.shared.generated.resources.schedule_number_of_results +import kotlinconfapp.ui_components.generated.resources.bookmark_24 +import kotlinconfapp.ui_components.generated.resources.search_24 import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource @@ -367,12 +379,15 @@ private fun Header( ) }, searchContent = { - // TODO update to new APIs - @OptIn(ExperimentalComposeUiApi::class) - BackHandler(true) { - onHeaderStateChange(MainHeaderContainerState.Title) - onSearchQueryChange("") - } + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = true, + onBackCompleted = { + onHeaderStateChange(MainHeaderContainerState.Title) + onSearchQueryChange("") + }, + ) + val filterItems by viewModel.filterItems.collectAsStateWithLifecycle() MainHeaderSearchBar( diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt index e7e23b38..1f60e858 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt @@ -17,15 +17,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlinconfapp.shared.generated.resources.Res +import kotlinconfapp.shared.generated.resources.speakers_error_no_data +import kotlinconfapp.shared.generated.resources.speakers_number_of_results +import kotlinconfapp.shared.generated.resources.speakers_title +import kotlinconfapp.ui_components.generated.resources.main_header_search_hint +import kotlinconfapp.ui_components.generated.resources.search_24 import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.kotlinconf.HideKeyboardOnDragHandler @@ -90,12 +97,15 @@ fun SpeakersScreen( ) }, searchContent = { - // TODO update to new APIs - @OptIn(ExperimentalComposeUiApi::class) - BackHandler(true) { - searchState = MainHeaderContainerState.Title - searchText = "" - } + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = true, + onBackCompleted = { + searchState = MainHeaderContainerState.Title + searchText = "" + }, + ) + MainHeaderSearchBar( searchValue = searchText, onSearchValueChange = { searchText = it }, From cffda2053b9e9305aa02131f096253007ff66d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 5 Nov 2025 09:03:35 +0100 Subject: [PATCH 18/28] Remove custom animation config --- .../jetbrains/kotlinconf/android/MainActivity.kt | 14 -------------- .../kotlin/org/jetbrains/kotlinconf/App.kt | 6 +----- .../kotlinconf/navigation/KotlinConfNavHost.kt | 14 +++----------- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt index a90af6e8..d5b5671c 100644 --- a/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/jetbrains/kotlinconf/android/MainActivity.kt @@ -8,13 +8,6 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.animation.ContentTransform -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.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.mmk.kmpnotifier.extensions.onCreateOrOnNewIntent import com.mmk.kmpnotifier.notification.NotifierManager @@ -50,13 +43,6 @@ class MainActivity : ComponentActivity() { window.isNavigationBarContrastEnforced = false } }, - popTransactionSpec = { - // TODO: review these magic numbers from Androidify - ContentTransform( - fadeIn(spring(dampingRatio = 1.0f, stiffness = 1600f)), - scaleOut(targetScale = 0.7f), - ) - } ) } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt index 09b53abc..6a0bca2d 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt @@ -1,7 +1,5 @@ package org.jetbrains.kotlinconf -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ContentTransform import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -12,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.scene.Scene import org.jetbrains.kotlinconf.navigation.KotlinConfNavHost import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme import org.koin.compose.koinInject @@ -20,7 +17,6 @@ import org.koin.compose.koinInject @Composable fun App( onThemeChange: ((isDarkTheme: Boolean) -> Unit)? = null, - popTransactionSpec: (AnimatedContentTransitionScope>.() -> ContentTransform)? = null, ) { val service = koinInject() val currentTheme by service.getTheme().collectAsStateWithLifecycle(initialValue = Theme.SYSTEM) @@ -50,7 +46,7 @@ fun App( .background(KotlinConfTheme.colors.mainBackground) ) { if (isOnboardingComplete != null) { - KotlinConfNavHost(isOnboardingComplete, popTransactionSpec) + KotlinConfNavHost(isOnboardingComplete) } } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 2cdc61a9..56df5f1d 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -1,7 +1,5 @@ package org.jetbrains.kotlinconf.navigation -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ContentTransform import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalUriHandler @@ -9,9 +7,7 @@ import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDe import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.scene.Scene import androidx.navigation3.ui.NavDisplay -import androidx.navigation3.ui.defaultPopTransitionSpec import kotlinx.coroutines.channels.Channel import org.jetbrains.kotlinconf.LocalFlags import org.jetbrains.kotlinconf.LocalNotificationId @@ -64,10 +60,7 @@ private fun NotificationHandler(backStack: MutableList) { } @Composable -internal fun KotlinConfNavHost( - isOnboardingComplete: Boolean, - popTransactionSpec: (AnimatedContentTransitionScope>.() -> ContentTransform)?, -) { +internal fun KotlinConfNavHost(isOnboardingComplete: Boolean) { val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen val appBackStack = rememberNavBackStack(startDestination) @@ -83,11 +76,10 @@ internal fun KotlinConfNavHost( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ), - popTransitionSpec = popTransactionSpec ?: defaultPopTransitionSpec(), ) } -fun EntryProviderScope.screens(backStack: MutableList) { +fun EntryProviderScope.screens(backStack: MutableList) { startScreens(backStack) // TODO inline these later entry { @@ -219,7 +211,7 @@ fun EntryProviderScope.screens(backStack: MutableList) { } } -fun EntryProviderScope.startScreens(backStack: MutableList) { +fun EntryProviderScope.startScreens(backStack: MutableList) { entry { val skipNotifications = LocalFlags.current.supportsNotifications.not() AppPrivacyNoticePrompt( From f2b83964885a8b730fbe9ec13c2f7884c6fb988e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 5 Nov 2025 09:04:32 +0100 Subject: [PATCH 19/28] Inline start screen setup in NavDisplay --- .../navigation/KotlinConfNavHost.kt | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 56df5f1d..6a15c4cf 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -79,8 +79,38 @@ internal fun KotlinConfNavHost(isOnboardingComplete: Boolean) { ) } -fun EntryProviderScope.screens(backStack: MutableList) { - startScreens(backStack) // TODO inline these later +private fun EntryProviderScope.screens(backStack: MutableList) { + entry { + val skipNotifications = LocalFlags.current.supportsNotifications.not() + AppPrivacyNoticePrompt( + onRejectNotice = { + if (skipNotifications) { + backStack.clear() + backStack.add(MainScreen) + } else { + backStack.add(StartNotificationsScreen) + } + }, + onAcceptNotice = { + if (skipNotifications) { + backStack.clear() + backStack.add(MainScreen) + } else { + backStack.add(StartNotificationsScreen) + } + }, + onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, + confirmationRequired = false, + ) + } + entry { + StartNotificationsScreen( + onDone = { + backStack.clear() + backStack.add(MainScreen) + } + ) + } entry { MainScreen(onNavigate = { backStack.add(it) }) @@ -210,38 +240,3 @@ fun EntryProviderScope.screens(backStack: MutableList) { ) } } - -fun EntryProviderScope.startScreens(backStack: MutableList) { - entry { - val skipNotifications = LocalFlags.current.supportsNotifications.not() - AppPrivacyNoticePrompt( - onRejectNotice = { - if (skipNotifications) { - backStack.clear() - backStack.add(MainScreen) - } else { - backStack.add(StartNotificationsScreen) - } - }, - onAcceptNotice = { - if (skipNotifications) { - backStack.clear() - backStack.add(MainScreen) - } else { - backStack.add(StartNotificationsScreen) - } - }, - onAppTermsOfUse = { backStack.add(AppTermsOfUseScreen) }, - confirmationRequired = false, - ) - } - - entry { - StartNotificationsScreen( - onDone = { - backStack.clear() - backStack.add(MainScreen) - } - ) - } -} From 63f8fff8f549eaf9d03a2fc4bc3aa32d4efd7bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 5 Nov 2025 10:03:30 +0100 Subject: [PATCH 20/28] Remove PlatformNavHandler --- .../navigation/PlatformNavHandler.android.kt | 9 --------- .../kotlinconf/navigation/KotlinConfNavHost.kt | 1 - .../kotlinconf/navigation/PlatformNavHandler.kt | 10 ---------- .../navigation/PlatformNavHandler.ios.kt | 9 --------- .../navigation/PlatformNavHandler.js.kt | 15 --------------- .../navigation/PlatformNavHandler.jvm.kt | 9 --------- .../navigation/PlatformNavHandler.wasm.kt | 15 --------------- 7 files changed, 68 deletions(-) delete mode 100644 shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.android.kt delete mode 100644 shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.kt delete mode 100644 shared/src/iosMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.ios.kt delete mode 100644 shared/src/jsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.js.kt delete mode 100644 shared/src/jvmMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.jvm.kt delete mode 100644 shared/src/wasmJsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.wasm.kt diff --git a/shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.android.kt b/shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.android.kt deleted file mode 100644 index e5e58c8d..00000000 --- a/shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.jetbrains.kotlinconf.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavHostController - -@Composable -internal actual fun PlatformNavHandler(navController: NavHostController) { - // Nothing to do -} diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 6a15c4cf..6776e8e4 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -65,7 +65,6 @@ internal fun KotlinConfNavHost(isOnboardingComplete: Boolean) { val appBackStack = rememberNavBackStack(startDestination) NotificationHandler(appBackStack) - //PlatformNavHandler(navController) NavDisplay( backStack = appBackStack, diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.kt deleted file mode 100644 index 156df022..00000000 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.jetbrains.kotlinconf.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavHostController - -/** - * Used for web target to synchronize the URL and browser history - */ -@Composable -internal expect fun PlatformNavHandler(navController: NavHostController) diff --git a/shared/src/iosMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.ios.kt b/shared/src/iosMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.ios.kt deleted file mode 100644 index e5e58c8d..00000000 --- a/shared/src/iosMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.ios.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.jetbrains.kotlinconf.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavHostController - -@Composable -internal actual fun PlatformNavHandler(navController: NavHostController) { - // Nothing to do -} diff --git a/shared/src/jsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.js.kt b/shared/src/jsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.js.kt deleted file mode 100644 index ee2ec49d..00000000 --- a/shared/src/jsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.js.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.jetbrains.kotlinconf.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.navigation.ExperimentalBrowserHistoryApi -import androidx.navigation.NavHostController -import androidx.navigation.bindToBrowserNavigation - -@OptIn(ExperimentalBrowserHistoryApi::class) -@Composable -internal actual fun PlatformNavHandler(navController: NavHostController) { - LaunchedEffect(Unit) { - navController.bindToBrowserNavigation() - } -} diff --git a/shared/src/jvmMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.jvm.kt b/shared/src/jvmMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.jvm.kt deleted file mode 100644 index e5e58c8d..00000000 --- a/shared/src/jvmMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.jvm.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.jetbrains.kotlinconf.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavHostController - -@Composable -internal actual fun PlatformNavHandler(navController: NavHostController) { - // Nothing to do -} diff --git a/shared/src/wasmJsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.wasm.kt b/shared/src/wasmJsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.wasm.kt deleted file mode 100644 index ee2ec49d..00000000 --- a/shared/src/wasmJsMain/kotlin/org/jetbrains/kotlinconf/navigation/PlatformNavHandler.wasm.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.jetbrains.kotlinconf.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.navigation.ExperimentalBrowserHistoryApi -import androidx.navigation.NavHostController -import androidx.navigation.bindToBrowserNavigation - -@OptIn(ExperimentalBrowserHistoryApi::class) -@Composable -internal actual fun PlatformNavHandler(navController: NavHostController) { - LaunchedEffect(Unit) { - navController.bindToBrowserNavigation() - } -} From 577c30b19af1c8016a6e13d07fef4dcf2099f41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 5 Nov 2025 10:03:49 +0100 Subject: [PATCH 21/28] Simplify bottom nav setup --- .../kotlinconf/screens/MainScreen.kt | 81 +++++++++---------- .../ui/components/MainNavigation.kt | 33 ++++---- 2 files changed, 55 insertions(+), 59 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 3c53b86e..7038490d 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -181,41 +181,35 @@ private fun isKeyboardOpen(): Boolean { return rememberUpdatedState(bottomInset > 300).value } +private val bottomNavDestinations: List> = listOf( + MainNavDestination( + label = Res.string.nav_destination_schedule, + icon = Res.drawable.clock_28, + iconSelected = Res.drawable.clock_28_fill, + route = ScheduleScreen, + ), + MainNavDestination( + label = Res.string.nav_destination_speakers, + icon = Res.drawable.team_28, + iconSelected = Res.drawable.team_28_fill, + route = SpeakersScreen, + ), + MainNavDestination( + label = Res.string.nav_destination_map, + icon = Res.drawable.location_28, + iconSelected = Res.drawable.location_28_fill, + route = MapScreen, + ), + MainNavDestination( + label = Res.string.nav_destination_info, + icon = Res.drawable.info_28, + iconSelected = Res.drawable.info_28_fill, + route = InfoScreen, + ), +) + @Composable private fun BottomNavigation(localBackStack: MutableList) { - val bottomNavDestinations: List> = - listOf( - MainNavDestination( - label = stringResource(Res.string.nav_destination_schedule), - icon = Res.drawable.clock_28, - iconSelected = Res.drawable.clock_28_fill, - route = ScheduleScreen, - routeClass = ScheduleScreen::class - ), - MainNavDestination( - label = stringResource(Res.string.nav_destination_speakers), - icon = Res.drawable.team_28, - iconSelected = Res.drawable.team_28_fill, - route = SpeakersScreen, - routeClass = SpeakersScreen::class - ), - MainNavDestination( - label = stringResource(Res.string.nav_destination_map), - icon = Res.drawable.location_28, - iconSelected = Res.drawable.location_28_fill, - route = MapScreen, - routeClass = MapScreen::class - ), - MainNavDestination( - label = stringResource(Res.string.nav_destination_info), - icon = Res.drawable.info_28, - iconSelected = Res.drawable.info_28_fill, - route = InfoScreen, - routeClass = InfoScreen::class - ), - ) - - // TODO check if we can simplify this val currentDestination = localBackStack.last() val currentBottomNavDestination = bottomNavDestinations.find { dest -> currentDestination == dest.route @@ -225,19 +219,18 @@ private fun BottomNavigation(localBackStack: MutableList) { MainNavigation( currentDestination = currentBottomNavDestination, destinations = bottomNavDestinations, - onSelect = { - localBackStack.apply { - val target = it.route - if (last() == target) { - return@apply - } + onSelect = { selectedDestination -> + // This screen is already selected, ignore it + if (localBackStack.last() == selectedDestination.route) { + return@MainNavigation + } - add(target) + // Add the new screen + localBackStack.add(selectedDestination.route) - if (size > 2) { - // Remove everything but the first and last entry - subList(1, lastIndex).clear() - } + // Keep only the first and last entry + if (localBackStack.size > 2) { + localBackStack.subList(1, localBackStack.lastIndex).clear() } }, ) diff --git a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt index d3b3b5ea..3b9ed49e 100644 --- a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt +++ b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt @@ -19,7 +19,10 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.kotlinconf.ui.generated.resources.UiRes import org.jetbrains.kotlinconf.ui.generated.resources.clock_28 import org.jetbrains.kotlinconf.ui.generated.resources.clock_28_fill @@ -31,7 +34,6 @@ import org.jetbrains.kotlinconf.ui.generated.resources.team_28 import org.jetbrains.kotlinconf.ui.generated.resources.team_28_fill import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme import org.jetbrains.kotlinconf.ui.theme.PreviewHelper -import kotlin.reflect.KClass private val MainNavigationButtonShape = RoundedCornerShape(8.dp) @@ -39,7 +41,7 @@ private val MainNavigationButtonShape = RoundedCornerShape(8.dp) private fun MainNavigationButton( iconResource: DrawableResource, iconFilledResource: DrawableResource, - contentDescription: String, + contentDescription: String?, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -66,11 +68,10 @@ private fun MainNavigationButton( } data class MainNavDestination( - val label: String, + val label: StringResource?, val icon: DrawableResource, val route: T, val iconSelected: DrawableResource = icon, - val routeClass: KClass<*>? = null, ) @Composable @@ -88,7 +89,7 @@ fun MainNavigation( MainNavigationButton( iconResource = destination.icon, iconFilledResource = destination.iconSelected, - contentDescription = destination.label, + contentDescription = destination.label?.let { stringResource(it) }, selected = destination == currentDestination, onClick = { onSelect(destination) }, modifier = Modifier.weight(1f), @@ -102,36 +103,38 @@ fun MainNavigation( internal fun MainNavigationPreview() { PreviewHelper { var currentDestination by remember { - mutableStateOf(MainNavDestination( - label = "Schedule", - icon = UiRes.drawable.clock_28, - iconSelected = UiRes.drawable.clock_28_fill, - route = "Schedule" - )) + mutableStateOf( + MainNavDestination( + label = null, + icon = UiRes.drawable.clock_28, + iconSelected = UiRes.drawable.clock_28_fill, + route = "Schedule" + ) + ) } MainNavigation( currentDestination = currentDestination, destinations = listOf( MainNavDestination( - label = "Info", + label = null, icon = UiRes.drawable.info_28, iconSelected = UiRes.drawable.info_28_fill, route = "Info" ), MainNavDestination( - label = "Schedule", + label = null, icon = UiRes.drawable.clock_28, iconSelected = UiRes.drawable.clock_28_fill, route = "Schedule" ), MainNavDestination( - label = "Speakers", + label = null, icon = UiRes.drawable.team_28, iconSelected = UiRes.drawable.team_28_fill, route = "Speakers" ), MainNavDestination( - label = "Map", + label = null, icon = UiRes.drawable.location_28, iconSelected = UiRes.drawable.location_28_fill, route = "Map" From 0196982dcfdd206200553acdbb49dea15ad0caa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 5 Nov 2025 19:06:08 +0100 Subject: [PATCH 22/28] Remove dependency on NavKey --- .../org/jetbrains/kotlinconf/navigation/AppRoute.kt | 3 +-- .../org/jetbrains/kotlinconf/navigation/BackStack.kt | 8 ++++++-- .../org/jetbrains/kotlinconf/navigation/MainRoute.kt | 3 +-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/AppRoute.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/AppRoute.kt index d9de38c8..aa6314b4 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/AppRoute.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/AppRoute.kt @@ -1,6 +1,5 @@ package org.jetbrains.kotlinconf.navigation -import androidx.navigation3.runtime.NavKey import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.jetbrains.kotlinconf.PartnerId @@ -8,7 +7,7 @@ import org.jetbrains.kotlinconf.SessionId import org.jetbrains.kotlinconf.SpeakerId @Serializable -sealed interface AppRoute : NavKey +sealed interface AppRoute @Serializable @SerialName("AboutConference") diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt index e83b601c..7f94e1f1 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt @@ -5,9 +5,13 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.saveable.rememberSerializable import androidx.navigation3.runtime.NavKey import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer +import kotlinx.serialization.KSerializer import kotlinx.serialization.serializer @Composable -inline fun rememberNavBackStack(vararg elements: T): MutableList { - return rememberSerializable(serializer = SnapshotStateListSerializer(serializer())) { mutableStateListOf(*elements) } +inline fun rememberNavBackStack(vararg elements: T): MutableList { + val elementSerializer = serializer() + return rememberSerializable(serializer = SnapshotStateListSerializer(elementSerializer)) { + mutableStateListOf(*elements) + } } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt index 1cfddca5..0d1938fb 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/MainRoute.kt @@ -1,11 +1,10 @@ package org.jetbrains.kotlinconf.navigation -import androidx.navigation3.runtime.NavKey import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -sealed interface MainRoute : NavKey +sealed interface MainRoute @Serializable data object ScheduleScreen : MainRoute From 77c5546fe01e9fcf6de614c622ecac27c51e3f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Tue, 11 Nov 2025 21:26:40 +0100 Subject: [PATCH 23/28] Add TODO for browser nav integration --- .../org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 6776e8e4..3e0700b4 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -64,6 +64,8 @@ internal fun KotlinConfNavHost(isOnboardingComplete: Boolean) { val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen val appBackStack = rememberNavBackStack(startDestination) + // TODO Integrate with browser navigation here https://github.com/JetBrains/kotlinconf-app/issues/557 + NotificationHandler(appBackStack) NavDisplay( From 931167fa6df14761579d73811121996c7f4e73f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Tue, 11 Nov 2025 21:33:20 +0100 Subject: [PATCH 24/28] Clean up imports after merge --- .../kotlinconf/screens/LicenseScreens.kt | 8 ------- .../kotlinconf/screens/MainScreen.kt | 22 ------------------- .../kotlinconf/screens/ScheduleScreen.kt | 10 --------- .../kotlinconf/screens/SpeakersScreen.kt | 6 ----- .../ui/components/MainNavigation.kt | 1 - 5 files changed, 47 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt index b11c0630..fee6b484 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/LicenseScreens.kt @@ -36,14 +36,6 @@ import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import com.mikepenz.aboutlibraries.entity.Library -import kotlinconfapp.shared.generated.resources.Res -import kotlinconfapp.shared.generated.resources.licenses_number_of_results -import kotlinconfapp.shared.generated.resources.licenses_title -import kotlinconfapp.ui_components.generated.resources.UiRes -import kotlinconfapp.ui_components.generated.resources.arrow_left_24 -import kotlinconfapp.ui_components.generated.resources.main_header_back -import kotlinconfapp.ui_components.generated.resources.main_header_search_hint -import kotlinconfapp.ui_components.generated.resources.search_24 import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.kotlinconf.HideKeyboardOnDragHandler diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 7038490d..61eb7379 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -29,28 +29,6 @@ import androidx.navigation3.ui.NavDisplay import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState -import kotlinconfapp.shared.generated.resources.Res -import kotlinconfapp.shared.generated.resources.clock_28 -import kotlinconfapp.shared.generated.resources.clock_28_fill -import kotlinconfapp.shared.generated.resources.info_28 -import kotlinconfapp.shared.generated.resources.info_28_fill -import kotlinconfapp.shared.generated.resources.location_28 -import kotlinconfapp.shared.generated.resources.location_28_fill -import kotlinconfapp.shared.generated.resources.nav_destination_info -import kotlinconfapp.shared.generated.resources.nav_destination_map -import kotlinconfapp.shared.generated.resources.nav_destination_schedule -import kotlinconfapp.shared.generated.resources.nav_destination_speakers -import kotlinconfapp.shared.generated.resources.team_28 -import kotlinconfapp.shared.generated.resources.team_28_fill -import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import org.jetbrains.compose.resources.stringResource import org.jetbrains.kotlinconf.ConferenceService import org.jetbrains.kotlinconf.LocalFlags import org.jetbrains.kotlinconf.URLs diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt index f2a70264..7a821838 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt @@ -47,16 +47,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState -import kotlinconfapp.shared.generated.resources.Res -import kotlinconfapp.shared.generated.resources.nav_destination_schedule -import kotlinconfapp.shared.generated.resources.schedule_action_filter_bookmarked -import kotlinconfapp.shared.generated.resources.schedule_action_search -import kotlinconfapp.shared.generated.resources.schedule_error_no_data -import kotlinconfapp.shared.generated.resources.schedule_in_x_minutes -import kotlinconfapp.shared.generated.resources.schedule_label_no_bookmarks -import kotlinconfapp.shared.generated.resources.schedule_number_of_results -import kotlinconfapp.ui_components.generated.resources.bookmark_24 -import kotlinconfapp.ui_components.generated.resources.search_24 import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt index 1f60e858..bee89d54 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt @@ -27,12 +27,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState -import kotlinconfapp.shared.generated.resources.Res -import kotlinconfapp.shared.generated.resources.speakers_error_no_data -import kotlinconfapp.shared.generated.resources.speakers_number_of_results -import kotlinconfapp.shared.generated.resources.speakers_title -import kotlinconfapp.ui_components.generated.resources.main_header_search_hint -import kotlinconfapp.ui_components.generated.resources.search_24 import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.kotlinconf.HideKeyboardOnDragHandler diff --git a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt index 3b9ed49e..872da52c 100644 --- a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt +++ b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainNavigation.kt @@ -22,7 +22,6 @@ import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.kotlinconf.ui.generated.resources.UiRes import org.jetbrains.kotlinconf.ui.generated.resources.clock_28 import org.jetbrains.kotlinconf.ui.generated.resources.clock_28_fill From b1dfdb08d415631f3f964c12e2b0506526e0b212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Tue, 11 Nov 2025 21:33:36 +0100 Subject: [PATCH 25/28] Remove previous nav dependency --- gradle/libs.versions.toml | 9 +++------ ui-components/build.gradle.kts | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d87355a2..0604d84f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ androidx-activityCompose = "1.11.0" androidx-core-ktx = "1.17.0" androidx-core-splashscreen = "1.0.1" androidx-lifecycle = "2.10.0-alpha04" -androidx-navigation = "2.9.1" +androidx-navigation3 = "1.0.0-alpha04" androidx-preference = "1.2.1" coil = "3.3.0" compose-multiplatform = "1.10.0-beta01" @@ -32,7 +32,6 @@ markdown = "0.37.0" multiplatform-settings = "1.3.0" postgresql = "42.7.7" slf4jNop = "2.0.17" -nav3Core = "1.0.0+dev3105" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } @@ -40,11 +39,10 @@ android-svg = { module = "com.caverock:androidsvg-aar", version.ref = "android-s androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } -androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } -androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } -androidx-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" } +androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" } androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } @@ -53,7 +51,6 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-material-ripple = { module = "org.jetbrains.compose.material:material-ripple", version.ref = "compose-multiplatform" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } -compose-ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform" } compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } doistx-normalize = { module = "com.doist.x:normalize", version.ref = "doistx-normalize" } diff --git a/ui-components/build.gradle.kts b/ui-components/build.gradle.kts index d37159a9..23175601 100644 --- a/ui-components/build.gradle.kts +++ b/ui-components/build.gradle.kts @@ -42,7 +42,6 @@ kotlin { implementation(libs.coil.network.ktor3) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.navigation.compose) implementation(libs.multiplatform.markdown.renderer) } From 93eabedc961bdc12e2ba75f3b3d6675d0bce56e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Tue, 11 Nov 2025 21:39:28 +0100 Subject: [PATCH 26/28] Add type constraint to deep link handling --- .../org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 3e0700b4..1ceee63e 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -47,14 +47,13 @@ fun navigateToSession(sessionId: SessionId) { notificationNavRequests.trySend(SessionScreen(sessionId)) } -private val notificationNavRequests = Channel(capacity = 1) +private val notificationNavRequests = Channel(capacity = 1) @Composable private fun NotificationHandler(backStack: MutableList) { LaunchedEffect(Unit) { while (true) { - val destination: Any = notificationNavRequests.receive() - backStack.add(destination as AppRoute) + backStack.add(notificationNavRequests.receive()) } } } From ddeeb64f08686c998ebe72eae544c940aee9b9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 12 Nov 2025 11:07:49 +0100 Subject: [PATCH 27/28] Use AnimatedContent instead of nested navigation in MainScreen --- .../kotlinconf/navigation/BackStack.kt | 17 -- .../navigation/KotlinConfNavHost.kt | 9 +- .../kotlinconf/screens/MainScreen.kt | 147 ++++++++---------- 3 files changed, 76 insertions(+), 97 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt deleted file mode 100644 index 7f94e1f1..00000000 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/BackStack.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.jetbrains.kotlinconf.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.saveable.rememberSerializable -import androidx.navigation3.runtime.NavKey -import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer -import kotlinx.serialization.KSerializer -import kotlinx.serialization.serializer - -@Composable -inline fun rememberNavBackStack(vararg elements: T): MutableList { - val elementSerializer = serializer() - return rememberSerializable(serializer = SnapshotStateListSerializer(elementSerializer)) { - mutableStateListOf(*elements) - } -} diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 1ceee63e..5bda42a4 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -2,12 +2,15 @@ package org.jetbrains.kotlinconf.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer import kotlinx.coroutines.channels.Channel import org.jetbrains.kotlinconf.LocalFlags import org.jetbrains.kotlinconf.LocalNotificationId @@ -60,8 +63,10 @@ private fun NotificationHandler(backStack: MutableList) { @Composable internal fun KotlinConfNavHost(isOnboardingComplete: Boolean) { - val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen - val appBackStack = rememberNavBackStack(startDestination) + val appBackStack = rememberSerializable(serializer = SnapshotStateListSerializer()) { + val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen + mutableStateListOf(startDestination) + } // TODO Integrate with browser navigation here https://github.com/JetBrains/kotlinconf-app/issues/557 diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt index 61eb7379..f25a634b 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt @@ -1,5 +1,6 @@ package org.jetbrains.kotlinconf.screens +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition @@ -17,15 +18,17 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState @@ -59,7 +62,6 @@ import org.jetbrains.kotlinconf.navigation.SessionScreen import org.jetbrains.kotlinconf.navigation.SettingsScreen import org.jetbrains.kotlinconf.navigation.SpeakerDetailScreen import org.jetbrains.kotlinconf.navigation.SpeakersScreen -import org.jetbrains.kotlinconf.navigation.rememberNavBackStack import org.jetbrains.kotlinconf.ui.components.Divider import org.jetbrains.kotlinconf.ui.components.MainNavDestination import org.jetbrains.kotlinconf.ui.components.MainNavigation @@ -83,73 +85,75 @@ fun MainScreen( .background(color = KotlinConfTheme.colors.mainBackground) .windowInsetsPadding(WindowInsets.safeDrawing) ) { - val localBackStack = rememberNavBackStack(ScheduleScreen) + var currentIndex by rememberSaveable { mutableIntStateOf(0) } - NavDisplay( - backStack = localBackStack, + val saveableStateHolder: SaveableStateHolder = rememberSaveableStateHolder() + + if (currentIndex > 0 && LocalFlags.current.enableBackOnMainScreens) { + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = true, + onBackCompleted = { currentIndex = 0 }, + ) + } + + AnimatedContent( + targetState = currentIndex, modifier = Modifier .fillMaxWidth() .weight(1f), transitionSpec = { NoContentTransition }, - popTransitionSpec = { NoContentTransition }, - predictivePopTransitionSpec = { NoContentTransition }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = entryProvider { - entry { - MainBackHandler() - val uriHandler = LocalUriHandler.current - InfoScreen( - onAboutConf = { onNavigate(AboutConferenceScreen) }, - onAboutApp = { onNavigate(AboutAppScreen) }, - onOurPartners = { onNavigate(PartnersScreen) }, - onCodeOfConduct = { onNavigate(CodeOfConductScreen) }, - onTwitter = { uriHandler.openUri(URLs.TWITTER) }, - onSlack = { uriHandler.openUri(URLs.SLACK) }, - onBluesky = { uriHandler.openUri(URLs.BLUESKY) }, - onSettings = { onNavigate(SettingsScreen) }, - ) - } - entry { - MainBackHandler() - SpeakersScreen( - onSpeaker = { onNavigate(SpeakerDetailScreen(it)) } - ) - } - entry { - MainBackHandler() - ScheduleScreen( - onSession = { onNavigate(SessionScreen(it)) }, - onPrivacyNoticeNeeded = { onNavigate(AppPrivacyNoticePrompt) }, - onRequestFeedbackWithComment = { sessionId -> - onNavigate(SessionScreen(sessionId, openedForFeedback = true)) - }, - ) - } - entry { - MainBackHandler() - MapScreen() - } + ) { index -> + saveableStateHolder.SaveableStateProvider(index) { + MainScreenContent(bottomNavDestinations[index].route, onNavigate) } - ) + } AnimatedVisibility(!isKeyboardOpen(), enter = fadeIn(snap()), exit = fadeOut(snap())) { - BottomNavigation(localBackStack) + BottomNavigation( + currentIndex = currentIndex, + onSelect = { selected -> currentIndex = selected } + ) } } } @Composable -private fun MainBackHandler() { - if (!LocalFlags.current.enableBackOnMainScreens) { - // Prevent back navigation - NavigationBackHandler( - state = rememberNavigationEventState(NavigationEventInfo.None), - isBackEnabled = true, - onBackCompleted = { /* Do nothing */ }, - ) +private fun MainScreenContent(route: MainRoute, onNavigate: (AppRoute) -> Unit) { + when (route) { + ScheduleScreen -> { + ScheduleScreen( + onSession = { onNavigate(SessionScreen(it)) }, + onPrivacyNoticeNeeded = { onNavigate(AppPrivacyNoticePrompt) }, + onRequestFeedbackWithComment = { sessionId -> + onNavigate(SessionScreen(sessionId, openedForFeedback = true)) + }, + ) + } + + SpeakersScreen -> { + SpeakersScreen( + onSpeaker = { onNavigate(SpeakerDetailScreen(it)) } + ) + } + + MapScreen -> { + MapScreen() + } + + InfoScreen -> { + val uriHandler = LocalUriHandler.current + InfoScreen( + onAboutConf = { onNavigate(AboutConferenceScreen) }, + onAboutApp = { onNavigate(AboutAppScreen) }, + onOurPartners = { onNavigate(PartnersScreen) }, + onCodeOfConduct = { onNavigate(CodeOfConductScreen) }, + onTwitter = { uriHandler.openUri(URLs.TWITTER) }, + onSlack = { uriHandler.openUri(URLs.SLACK) }, + onBluesky = { uriHandler.openUri(URLs.BLUESKY) }, + onSettings = { onNavigate(SettingsScreen) }, + ) + } } } @@ -187,29 +191,16 @@ private val bottomNavDestinations: List> = listOf( ) @Composable -private fun BottomNavigation(localBackStack: MutableList) { - val currentDestination = localBackStack.last() - val currentBottomNavDestination = bottomNavDestinations.find { dest -> - currentDestination == dest.route - } - +private fun BottomNavigation( + currentIndex: Int, + onSelect: (Int) -> Unit, +) { Divider(thickness = 1.dp, color = KotlinConfTheme.colors.strokePale) MainNavigation( - currentDestination = currentBottomNavDestination, + currentDestination = bottomNavDestinations[currentIndex], destinations = bottomNavDestinations, onSelect = { selectedDestination -> - // This screen is already selected, ignore it - if (localBackStack.last() == selectedDestination.route) { - return@MainNavigation - } - - // Add the new screen - localBackStack.add(selectedDestination.route) - - // Keep only the first and last entry - if (localBackStack.size > 2) { - localBackStack.subList(1, localBackStack.lastIndex).clear() - } + onSelect(bottomNavDestinations.indexOf(selectedDestination)) }, ) } From 82914e0e65a1fdfb5971562328aacdc900b7c6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 12 Nov 2025 13:00:04 +0100 Subject: [PATCH 28/28] Add explicit type for backstack --- .../kotlinconf/navigation/KotlinConfNavHost.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt index 5bda42a4..ea3bb03d 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/navigation/KotlinConfNavHost.kt @@ -63,19 +63,20 @@ private fun NotificationHandler(backStack: MutableList) { @Composable internal fun KotlinConfNavHost(isOnboardingComplete: Boolean) { - val appBackStack = rememberSerializable(serializer = SnapshotStateListSerializer()) { - val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen - mutableStateListOf(startDestination) - } + val backstack: MutableList = + rememberSerializable(serializer = SnapshotStateListSerializer()) { + val startDestination = if (isOnboardingComplete) MainScreen else StartPrivacyNoticeScreen + mutableStateListOf(startDestination) + } // TODO Integrate with browser navigation here https://github.com/JetBrains/kotlinconf-app/issues/557 - NotificationHandler(appBackStack) + NotificationHandler(backstack) NavDisplay( - backStack = appBackStack, + backStack = backstack, entryProvider = entryProvider { - screens(appBackStack) + screens(backstack) }, entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(),