diff --git a/Jetsnack/app/build.gradle.kts b/Jetsnack/app/build.gradle.kts index b66ac2188..aabbb05d0 100644 --- a/Jetsnack/app/build.gradle.kts +++ b/Jetsnack/app/build.gradle.kts @@ -21,6 +21,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -118,8 +119,15 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation.compose) implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.startup) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.foundation) diff --git a/Jetsnack/app/proguard-rules.pro b/Jetsnack/app/proguard-rules.pro index f8f76182d..de9c52a5c 100644 --- a/Jetsnack/app/proguard-rules.pro +++ b/Jetsnack/app/proguard-rules.pro @@ -34,4 +34,7 @@ -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE --keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } \ No newline at end of file +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } + +-keep class androidx.work.** { *; } +-keep class androidx.work.impl.** { *; } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt index b98bfae26..7a69bf448 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt @@ -20,7 +20,7 @@ package com.example.jetsnack.ui -import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope @@ -28,6 +28,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding @@ -35,23 +36,28 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.navArgument +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay import com.example.jetsnack.ui.components.JetsnackScaffold import com.example.jetsnack.ui.components.JetsnackSnackbar import com.example.jetsnack.ui.components.rememberJetsnackScaffoldState +import com.example.jetsnack.ui.home.Feed import com.example.jetsnack.ui.home.HomeSections import com.example.jetsnack.ui.home.JetsnackBottomBar -import com.example.jetsnack.ui.home.addHomeGraph -import com.example.jetsnack.ui.home.composableWithCompositionLocal -import com.example.jetsnack.ui.navigation.MainDestinations -import com.example.jetsnack.ui.navigation.rememberJetsnackNavController +import com.example.jetsnack.ui.home.Profile +import com.example.jetsnack.ui.home.cart.Cart +import com.example.jetsnack.ui.home.search.Search +import com.example.jetsnack.ui.navigation.CartKey +import com.example.jetsnack.ui.navigation.FeedKey +import com.example.jetsnack.ui.navigation.ProfileKey +import com.example.jetsnack.ui.navigation.SearchKey +import com.example.jetsnack.ui.navigation.SnackDetailKey +import com.example.jetsnack.ui.navigation.addHomeSection +import com.example.jetsnack.ui.navigation.addSnackDetail +import com.example.jetsnack.ui.navigation.currentHomeSectionKey import com.example.jetsnack.ui.snackdetail.SnackDetail import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring @@ -61,110 +67,101 @@ import com.example.jetsnack.ui.theme.JetsnackTheme @Composable fun JetsnackApp() { JetsnackTheme { - val jetsnackNavController = rememberJetsnackNavController() + + val backStack = rememberNavBackStack(FeedKey) + val jetsnackScaffoldState = rememberJetsnackScaffoldState() + SharedTransitionLayout { CompositionLocalProvider( LocalSharedTransitionScope provides this, ) { - NavHost( - navController = jetsnackNavController.navController, - startDestination = MainDestinations.HOME_ROUTE, - ) { - composableWithCompositionLocal( - route = MainDestinations.HOME_ROUTE, - ) { backStackEntry -> - MainContainer( - onSnackSelected = jetsnackNavController::navigateToSnackDetail, - ) - } + JetsnackScaffold( + bottomBar = { + val showBottomBar = backStack.last() !is SnackDetailKey - composableWithCompositionLocal( - "${MainDestinations.SNACK_DETAIL_ROUTE}/" + - "{${MainDestinations.SNACK_ID_KEY}}" + - "?origin={${MainDestinations.ORIGIN}}", - arguments = listOf( - navArgument(MainDestinations.SNACK_ID_KEY) { - type = NavType.LongType + AnimatedVisibility( + visible = showBottomBar, + enter = fadeIn(nonSpatialExpressiveSpring()) + slideInVertically( + spatialExpressiveSpring(), + ) { + it }, - ), - - ) { backStackEntry -> - val arguments = requireNotNull(backStackEntry.arguments) - val snackId = arguments.getLong(MainDestinations.SNACK_ID_KEY) - val origin = arguments.getString(MainDestinations.ORIGIN) - SnackDetail( - snackId, - origin = origin ?: "", - upPress = jetsnackNavController::upPress, + exit = fadeOut(nonSpatialExpressiveSpring()) + slideOutVertically( + spatialExpressiveSpring(), + ) { + it + }, + ) { + JetsnackBottomBar( + tabs = HomeSections.entries.toTypedArray(), + currentKey = backStack.currentHomeSectionKey(), + onItemClick = { navKey -> backStack.addHomeSection(navKey) }, + modifier = Modifier + .renderInSharedTransitionScopeOverlay( + zIndexInOverlay = 1f, + ), + ) + } + }, + snackbarHost = { + SnackbarHost( + hostState = it, + modifier = Modifier.systemBarsPadding(), + snackbar = { snackbarData -> JetsnackSnackbar(snackbarData) }, ) - } - } - } - } - } -} + }, + snackBarHostState = jetsnackScaffoldState.snackBarHostState, + ) { padding -> -@Composable -fun MainContainer(modifier: Modifier = Modifier, onSnackSelected: (Long, String, NavBackStackEntry) -> Unit) { - val jetsnackScaffoldState = rememberJetsnackScaffoldState() - val nestedNavController = rememberJetsnackNavController() - val navBackStackEntry by nestedNavController.navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route - val sharedTransitionScope = LocalSharedTransitionScope.current - ?: throw IllegalStateException("No SharedElementScope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalStateException("No SharedElementScope found") - JetsnackScaffold( - bottomBar = { - with(animatedVisibilityScope) { - with(sharedTransitionScope) { - JetsnackBottomBar( - tabs = HomeSections.entries.toTypedArray(), - currentRoute = currentRoute ?: HomeSections.FEED.route, - navigateToRoute = nestedNavController::navigateToBottomBarRoute, - modifier = Modifier - .renderInSharedTransitionScopeOverlay( - zIndexInOverlay = 1f, - ) - .animateEnterExit( - enter = fadeIn(nonSpatialExpressiveSpring()) + slideInVertically( - spatialExpressiveSpring(), - ) { - it - }, - exit = fadeOut(nonSpatialExpressiveSpring()) + slideOutVertically( - spatialExpressiveSpring(), - ) { - it - }, - ), + val modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + + val transitionSpec = fadeIn(nonSpatialExpressiveSpring()) togetherWith + fadeOut(nonSpatialExpressiveSpring()) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sharedTransitionScope = this@SharedTransitionLayout, + entryProvider = entryProvider { + entry { + Feed( + onSnackClick = backStack.addSnackDetail(), + modifier = modifier, + ) + } + entry { + Cart( + onSnackClick = backStack.addSnackDetail(), + modifier = modifier, + ) + } + entry { + Search( + onSnackClick = backStack.addSnackDetail(), + modifier = modifier, + ) + } + entry { + Profile(modifier) + } + entry { key -> + SnackDetail( + key.snackId, + origin = key.origin, + upPress = { backStack.removeLastOrNull() }, + ) + } + }, + transitionSpec = { transitionSpec }, + popTransitionSpec = { transitionSpec }, + predictivePopTransitionSpec = { transitionSpec }, ) } } - }, - modifier = modifier, - snackbarHost = { - SnackbarHost( - hostState = it, - modifier = Modifier.systemBarsPadding(), - snackbar = { snackbarData -> JetsnackSnackbar(snackbarData) }, - ) - }, - snackBarHostState = jetsnackScaffoldState.snackBarHostState, - ) { padding -> - NavHost( - navController = nestedNavController.navController, - startDestination = HomeSections.FEED.route, - ) { - addHomeGraph( - onSnackSelected = onSnackSelected, - modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding), - ) } } } -val LocalNavAnimatedVisibilityScope = compositionLocalOf { null } val LocalSharedTransitionScope = compositionLocalOf { null } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt index 028ee2b1c..4196dc849 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt @@ -20,6 +20,7 @@ package com.example.jetsnack.ui.components import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -69,6 +70,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.navigation3.ui.LocalNavAnimatedContentScope import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetsnack.R @@ -76,7 +78,6 @@ import com.example.jetsnack.model.CollectionType import com.example.jetsnack.model.Snack import com.example.jetsnack.model.SnackCollection import com.example.jetsnack.model.snacks -import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope import com.example.jetsnack.ui.LocalSharedTransitionScope import com.example.jetsnack.ui.SnackSharedElementKey import com.example.jetsnack.ui.SnackSharedElementType @@ -199,8 +200,7 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String ) { val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No sharedTransitionScope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalStateException("No animatedVisibilityScope found") + val animatedContentScope = LocalNavAnimatedContentScope.current with(sharedTransitionScope) { Column( @@ -225,7 +225,7 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String type = SnackSharedElementType.Image, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, boundsTransform = snackDetailBoundsTransform, ), ) @@ -244,7 +244,7 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String type = SnackSharedElementType.Title, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, enter = fadeIn(nonSpatialExpressiveSpring()), exit = fadeOut(nonSpatialExpressiveSpring()), resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), @@ -268,10 +268,10 @@ private fun HighlightSnackItem( ) { val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No Scope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalStateException("No Scope found") + val animatedContentScope = LocalNavAnimatedContentScope.current + with(sharedTransitionScope) { - val roundedCornerAnimation by animatedVisibilityScope.transition + val roundedCornerAnimation by animatedContentScope.transition .animateDp(label = "rounded corner") { enterExit: EnterExitState -> when (enterExit) { EnterExitState.PreEnter -> 0.dp @@ -292,7 +292,7 @@ private fun HighlightSnackItem( type = SnackSharedElementType.Bounds, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, boundsTransform = snackDetailBoundsTransform, clipInOverlayDuringTransition = OverlayClip( RoundedCornerShape( @@ -339,7 +339,7 @@ private fun HighlightSnackItem( type = SnackSharedElementType.Background, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, boundsTransform = snackDetailBoundsTransform, enter = fadeIn(nonSpatialExpressiveSpring()), exit = fadeOut(nonSpatialExpressiveSpring()), @@ -374,7 +374,7 @@ private fun HighlightSnackItem( type = SnackSharedElementType.Image, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, exit = fadeOut(nonSpatialExpressiveSpring()), enter = fadeIn(nonSpatialExpressiveSpring()), boundsTransform = snackDetailBoundsTransform, @@ -401,7 +401,7 @@ private fun HighlightSnackItem( type = SnackSharedElementType.Title, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, enter = fadeIn(nonSpatialExpressiveSpring()), exit = fadeOut(nonSpatialExpressiveSpring()), boundsTransform = snackDetailBoundsTransform, @@ -425,7 +425,7 @@ private fun HighlightSnackItem( type = SnackSharedElementType.Tagline, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, enter = fadeIn(nonSpatialExpressiveSpring()), exit = fadeOut(nonSpatialExpressiveSpring()), boundsTransform = snackDetailBoundsTransform, @@ -494,10 +494,10 @@ fun SnackCardPreview() { fun JetsnackPreviewWrapper(content: @Composable () -> Unit) { JetsnackTheme { SharedTransitionLayout { - AnimatedVisibility(visible = true) { + AnimatedContent(targetState = true) { _ -> CompositionLocalProvider( LocalSharedTransitionScope provides this@SharedTransitionLayout, - LocalNavAnimatedVisibilityScope provides this, + LocalNavAnimatedContentScope provides this, ) { content() } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt index b706ea8bb..b12638403 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt @@ -40,8 +40,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.example.jetsnack.R -import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope import com.example.jetsnack.ui.LocalSharedTransitionScope import com.example.jetsnack.ui.components.JetsnackDivider import com.example.jetsnack.ui.components.JetsnackPreviewWrapper @@ -55,7 +55,7 @@ fun DestinationBar(modifier: Modifier = Modifier) { val sharedElementScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No shared element scope") val navAnimatedScope = - LocalNavAnimatedVisibilityScope.current ?: throw IllegalStateException("No nav scope") + LocalNavAnimatedContentScope.current with(sharedElementScope) { with(navAnimatedScope) { Column( diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt index 28d1f4b92..0c19418e5 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt @@ -19,16 +19,10 @@ package com.example.jetsnack.ui.home import androidx.annotation.DrawableRes import androidx.annotation.FloatRange import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -44,7 +38,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -67,110 +60,40 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.core.os.ConfigurationCompat -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDeepLink -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navDeepLink +import androidx.navigation3.runtime.NavKey import com.example.jetsnack.R -import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope import com.example.jetsnack.ui.components.JetsnackSurface -import com.example.jetsnack.ui.home.cart.Cart -import com.example.jetsnack.ui.home.search.Search -import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.navigation.CartKey +import com.example.jetsnack.ui.navigation.FeedKey +import com.example.jetsnack.ui.navigation.ProfileKey +import com.example.jetsnack.ui.navigation.SearchKey import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.JetsnackTheme import java.util.Locale -fun NavGraphBuilder.composableWithCompositionLocal( - route: String, - arguments: List = emptyList(), - deepLinks: List = emptyList(), - enterTransition: ( - @JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition? - )? = { - fadeIn(nonSpatialExpressiveSpring()) - }, - exitTransition: ( - @JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition? - )? = { - fadeOut(nonSpatialExpressiveSpring()) - }, - popEnterTransition: ( - @JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition? - )? = - enterTransition, - popExitTransition: ( - @JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition? - )? = - exitTransition, - content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, -) { - composable( - route, - arguments, - deepLinks, - enterTransition, - exitTransition, - popEnterTransition, - popExitTransition, - ) { - CompositionLocalProvider( - LocalNavAnimatedVisibilityScope provides this@composable, - ) { - content(it) - } - } -} +enum class HomeSections(@StringRes val title: Int, @DrawableRes val icon: Int, val route: NavKey) { + FEED(R.string.home_feed, R.drawable.ic_home, FeedKey), + SEARCH(R.string.home_search, R.drawable.ic_search, SearchKey), + CART(R.string.home_cart, R.drawable.ic_shopping_cart, CartKey), + PROFILE(R.string.home_profile, R.drawable.ic_account_circle, ProfileKey), + ; -fun NavGraphBuilder.addHomeGraph(onSnackSelected: (Long, String, NavBackStackEntry) -> Unit, modifier: Modifier = Modifier) { - composable(HomeSections.FEED.route) { from -> - Feed( - onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, - modifier, - ) + companion object { + val routes = entries.map { it.route } } - composable(HomeSections.SEARCH.route) { from -> - Search( - onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, - modifier, - ) - } - composable( - HomeSections.CART.route, - deepLinks = listOf( - navDeepLink { uriPattern = "https://jetsnack.example.com/home/cart" }, - ), - ) { from -> - Cart( - onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, - modifier, - ) - } - composable(HomeSections.PROFILE.route) { - Profile(modifier) - } -} - -enum class HomeSections(@StringRes val title: Int, @DrawableRes val icon: Int, val route: String) { - FEED(R.string.home_feed, R.drawable.ic_home, "home/feed"), - SEARCH(R.string.home_search, R.drawable.ic_search, "home/search"), - CART(R.string.home_cart, R.drawable.ic_shopping_cart, "home/cart"), - PROFILE(R.string.home_profile, R.drawable.ic_account_circle, "home/profile"), } @Composable fun JetsnackBottomBar( tabs: Array, - currentRoute: String, - navigateToRoute: (String) -> Unit, + currentKey: NavKey, + onItemClick: (NavKey) -> Unit, modifier: Modifier = Modifier, color: Color = JetsnackTheme.colors.iconPrimary, contentColor: Color = JetsnackTheme.colors.iconInteractive, ) { val routes = remember { tabs.map { it.route } } - val currentSection = tabs.first { it.route == currentRoute } + val currentSection = tabs.first { it.route == currentKey } JetsnackSurface( modifier = modifier, @@ -219,7 +142,7 @@ fun JetsnackBottomBar( ) }, selected = selected, - onSelected = { navigateToRoute(section.route) }, + onSelected = { onItemClick(section.route) }, animSpec = springSpec, modifier = BottomNavigationItemPadding .clip(BottomNavIndicatorShape), @@ -422,8 +345,8 @@ private fun JetsnackBottomNavPreview() { JetsnackTheme { JetsnackBottomBar( tabs = HomeSections.entries.toTypedArray(), - currentRoute = "home/feed", - navigateToRoute = { }, + currentKey = FeedKey, + onItemClick = { }, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt deleted file mode 100644 index 1d16f9981..000000000 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.ui.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.lifecycle.Lifecycle -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDestination -import androidx.navigation.NavGraph -import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController - -/** - * Destinations used in the [JetsnackApp]. - */ -object MainDestinations { - const val HOME_ROUTE = "home" - const val SNACK_DETAIL_ROUTE = "snack" - const val SNACK_ID_KEY = "snackId" - const val ORIGIN = "origin" -} - -/** - * Remembers and creates an instance of [JetsnackNavController] - */ -@Composable -fun rememberJetsnackNavController(navController: NavHostController = rememberNavController()): JetsnackNavController = - remember(navController) { - JetsnackNavController(navController) - } - -/** - * Responsible for holding UI Navigation logic. - */ -@Stable -class JetsnackNavController(val navController: NavHostController) { - - // ---------------------------------------------------------- - // Navigation state source of truth - // ---------------------------------------------------------- - - fun upPress() { - navController.navigateUp() - } - - fun navigateToBottomBarRoute(route: String) { - if (route != navController.currentDestination?.route) { - navController.navigate(route) { - launchSingleTop = true - restoreState = true - // Pop up backstack to the first destination and save state. This makes going back - // to the start destination when pressing back in any other bottom tab. - popUpTo(findStartDestination(navController.graph).id) { - saveState = true - } - } - } - } - - fun navigateToSnackDetail(snackId: Long, origin: String, from: NavBackStackEntry) { - // In order to discard duplicated navigation events, we check the Lifecycle - if (from.lifecycleIsResumed()) { - navController.navigate("${MainDestinations.SNACK_DETAIL_ROUTE}/$snackId?origin=$origin") - } - } -} - -/** - * If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event. - * - * This is used to de-duplicate navigation events. - */ -private fun NavBackStackEntry.lifecycleIsResumed() = this.lifecycle.currentState == Lifecycle.State.RESUMED - -private val NavGraph.startDestination: NavDestination? - get() = findNode(startDestinationId) - -/** - * Copied from similar function in NavigationUI.kt - * - * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-ui/src/main/java/androidx/navigation/ui/NavigationUI.kt - */ -private tailrec fun findStartDestination(graph: NavDestination): NavDestination { - return if (graph is NavGraph) findStartDestination(graph.startDestination!!) else graph -} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/NavKeys.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/NavKeys.kt new file mode 100644 index 000000000..9690c848c --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/NavKeys.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.example.jetsnack.ui.home.HomeSections +import kotlinx.serialization.Serializable + +@Serializable +data object FeedKey : NavKey + +@Serializable +data object SearchKey : NavKey + +@Serializable +data object CartKey : NavKey + +@Serializable +data object ProfileKey : NavKey + +@Serializable +data class SnackDetailKey(val snackId: Long, val origin: String) : NavKey + +fun NavBackStack.addHomeSection(key: NavKey) { + // Remove everything except the Feed from the back stack. + removeAll { it !is FeedKey } + // Now add the key if it's not the Feed. + if (key !is FeedKey) { + add(key) + } +} + +fun NavBackStack.currentHomeSectionKey(): NavKey = findLast { it in HomeSections.routes } + ?: error("No HomeSection key found in the back stack") + +@Composable +fun NavBackStack.addSnackDetail(): (snackId: Long, origin: String) -> Unit { + val lifecycleOwner = LocalLifecycleOwner.current + return { snackId, origin -> + if (lifecycleOwner.lifecycle.currentState == Lifecycle.State.RESUMED) { + add(SnackDetailKey(snackId, origin)) + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt index 32cf6dd0c..88a17849a 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt @@ -95,11 +95,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp +import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.example.jetsnack.R import com.example.jetsnack.model.Snack import com.example.jetsnack.model.SnackCollection import com.example.jetsnack.model.SnackRepo -import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope import com.example.jetsnack.ui.LocalSharedTransitionScope import com.example.jetsnack.ui.SnackSharedElementKey import com.example.jetsnack.ui.SnackSharedElementType @@ -147,9 +147,9 @@ fun SnackDetail(snackId: Long, origin: String, upPress: () -> Unit) { val related = remember(snackId) { SnackRepo.getRelated(snackId) } val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No Scope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalStateException("No Scope found") - val roundedCornerAnim by animatedVisibilityScope.transition + val animatedContentScope = LocalNavAnimatedContentScope.current + + val roundedCornerAnim by animatedContentScope.transition .animateDp(label = "rounded corner") { enterExit: EnterExitState -> when (enterExit) { EnterExitState.PreEnter -> 20.dp @@ -169,7 +169,7 @@ fun SnackDetail(snackId: Long, origin: String, upPress: () -> Unit) { type = SnackSharedElementType.Bounds, ), ), - animatedVisibilityScope, + animatedContentScope, clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(roundedCornerAnim)), boundsTransform = snackDetailBoundsTransform, @@ -194,8 +194,7 @@ fun SnackDetail(snackId: Long, origin: String, upPress: () -> Unit) { private fun Header(snackId: Long, origin: String) { val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalArgumentException("No Scope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalArgumentException("No Scope found") + val animatedContentScope = LocalNavAnimatedContentScope.current with(sharedTransitionScope) { val brushColors = JetsnackTheme.colors.tornado1 @@ -223,7 +222,7 @@ private fun Header(snackId: Long, origin: String) { type = SnackSharedElementType.Background, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, boundsTransform = snackDetailBoundsTransform, enter = fadeIn(nonSpatialExpressiveSpring()), exit = fadeOut(nonSpatialExpressiveSpring()), @@ -250,9 +249,9 @@ private fun Header(snackId: Long, origin: String) { @Composable private fun SharedTransitionScope.Up(upPress: () -> Unit) { - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalArgumentException("No Scope found") - with(animatedVisibilityScope) { + val animatedContentScope = LocalNavAnimatedContentScope.current + + with(animatedContentScope) { IconButton( onClick = upPress, modifier = Modifier @@ -390,8 +389,7 @@ private fun Title(snack: Snack, origin: String, scrollProvider: () -> Int) { val minOffset = with(LocalDensity.current) { MinTitleOffset.toPx() } val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalArgumentException("No Scope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalArgumentException("No Scope found") + val animatedContentScope = LocalNavAnimatedContentScope.current with(sharedTransitionScope) { Column( @@ -422,7 +420,7 @@ private fun Title(snack: Snack, origin: String, scrollProvider: () -> Int) { type = SnackSharedElementType.Title, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, boundsTransform = snackDetailBoundsTransform, ) .wrapContentWidth(), @@ -442,13 +440,13 @@ private fun Title(snack: Snack, origin: String, scrollProvider: () -> Int) { type = SnackSharedElementType.Tagline, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, boundsTransform = snackDetailBoundsTransform, ) .wrapContentWidth(), ) Spacer(Modifier.height(4.dp)) - with(animatedVisibilityScope) { + with(animatedContentScope) { Text( text = formatPrice(snack.price), style = MaterialTheme.typography.titleLarge, @@ -486,8 +484,7 @@ private fun Image( ) { val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No sharedTransitionScope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalStateException("No animatedVisibilityScope found") + val animatedContentScope = LocalNavAnimatedContentScope.current with(sharedTransitionScope) { SnackImage( @@ -502,7 +499,7 @@ private fun Image( type = SnackSharedElementType.Image, ), ), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedContentScope, exit = fadeOut(), enter = fadeIn(), boundsTransform = snackDetailBoundsTransform, @@ -549,10 +546,10 @@ private fun CartBottomBar(modifier: Modifier = Modifier) { val (count, updateCount) = remember { mutableIntStateOf(1) } val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No Shared scope") - val animatedVisibilityScope = - LocalNavAnimatedVisibilityScope.current ?: throw IllegalStateException("No Shared scope") + val animatedContentScope = LocalNavAnimatedContentScope.current + with(sharedTransitionScope) { - with(animatedVisibilityScope) { + with(animatedContentScope) { JetsnackSurface( modifier = modifier .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 4f) diff --git a/Jetsnack/gradle/libs.versions.toml b/Jetsnack/gradle/libs.versions.toml index 9b01ac78d..0f47636a9 100644 --- a/Jetsnack/gradle/libs.versions.toml +++ b/Jetsnack/gradle/libs.versions.toml @@ -12,8 +12,10 @@ androidx-glance = "1.2.0-rc01" androidx-lifecycle = "2.8.2" androidx-lifecycle-compose = "2.10.0" androidx-lifecycle-runtime-compose = "2.10.0" -androidx-navigation = "2.9.7" +androidx-navigation3 = "1.1.0-alpha04" +androidx-lifecycle-navigation3 = "2.10.0" androidx-palette = "1.0.0" +androidx-startup = "1.2.0" androidx-test = "1.7.0" androidx-test-espresso = "3.7.0" androidx-test-ext-junit = "1.3.0" @@ -54,6 +56,8 @@ spotless = "8.2.1" # @keep targetSdk = "36" version-catalog-update = "1.0.1" +navigation3 = "1.0.0" +work = "2.10.0" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -98,13 +102,14 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } -androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } -androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidx-navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle-navigation3" } androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } @@ -120,6 +125,7 @@ androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-nav androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" } androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" } dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" }