Refactor NavigationState and make tests pass

pull/2003/head
Don Turner 1 month ago
parent 386d1a0a16
commit f6824858c4

@ -1,62 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.di
// TODO: Rename to `NiaNavigationStateProvider`
// Does this even need to be injected? Can't we just instantiate it directly using `rememberNavigationState`?
/*
@Module
@InstallIn(SingletonComponent::class)
object NavigationStateProvider {
@Provides
@Singleton
fun provideNavigationState(): NiaNavigationState =
NiaNavigationState(
//startKey = TopLevelDestination.FOR_YOU.key,
startKey = object : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
)
// TODO: Remove commented out code
//
// @Provides
// @Singleton
// fun provideNiaNavigator(
// state: NiaNavigatorState
// ): NiaNavigator =
// NiaNavigator(state)
*/
/**
* Registers feature modules' polymorphic serializers to support
* feature keys' save and restore by savedstate
* in [com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackViewModel].
*//*
@Provides
@Singleton
fun provideSerializersModule(
polymorphicModuleBuilders: Set<@JvmSuppressWildcards PolymorphicModuleBuilder<NiaNavKey>.() -> Unit>,
): SerializersModule = SerializersModule {
polymorphic(NiaNavKey::class) {
polymorphicModuleBuilders.forEach { it() }
}
}
}
*/

@ -1,135 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR
/**
* Type for the top level destinations in the application. Contains metadata about the destination
* that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
* @param route The route to use when navigating to this destination.
* @param baseRoute The highest ancestor of this destination. Defaults to [route], meaning that
* there is a single destination in that section of the app (no nested destinations).
*/
/*
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
val key: NiaNavKey,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name,
route = ForYouRoute::class,
key = ForYouRoute,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
route = BookmarksRoute::class,
key = BookmarksRoute,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
route = InterestsRoute::class,
key = InterestsRoute(null),
),
}
internal val TopLevelDestinations = TopLevelDestination.entries.associateBy { dest -> dest.key }
*/
val FOR_YOU = NavBarItem(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name,
key = ForYouRoute,
)
val BOOKMARKS = NavBarItem(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
key = BookmarksRoute,
)
val INTERESTS = NavBarItem(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
key = InterestsRoute(null)
)
val TOP_LEVEL_ROUTES = mapOf<NavKey, NavBarItem>(
ForYouRoute to FOR_YOU,
BookmarksRoute to BOOKMARKS,
InterestsRoute(null) to INTERESTS,
)
/**
* Type for the top level navigation items in the application. Contains UI information about the
* current route that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
* @param key The navigation key to use when navigating to this destination.
* there is a single destination in that section of the app (no nested destinations).
*/
data class NavBarItem(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val key: NavKey,
)

@ -1,41 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.navigation
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.ui.NavDisplay
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigator
import com.google.samples.apps.nowinandroid.core.navigation.toEntries
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun NiaNavDisplay(
niaNavigator: NiaNavigator,
entryProviderBuilders: Set<EntryProviderScope<NiaNavKey>.() -> Unit>,
) {
val listDetailStrategy = rememberListDetailSceneStrategy<NiaNavKey>()
val entries = niaNavigator.niaNavigationState.toEntries(entryProviderBuilders)
NavDisplay(
entries = entries,
sceneStrategy = listDetailStrategy,
onBack = { niaNavigator.pop() },
)
}

@ -0,0 +1,38 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Type for the top level navigation items in the application. Contains UI information about the
* current route that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
*/
data class TopLevelNavItem(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
)

@ -0,0 +1,53 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.navigation
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR
val FOR_YOU = TopLevelNavItem(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name,
)
val BOOKMARKS = TopLevelNavItem(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
)
val INTERESTS = TopLevelNavItem(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
)
val TOP_LEVEL_ROUTES = mapOf(
ForYouRoute to FOR_YOU,
BookmarksRoute to BOOKMARKS,
InterestsRoute(null) to INTERESTS,
)

@ -72,8 +72,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.core.navigation.simple.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.simple.toEntries
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.toEntries
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.bookmarksEntry
import com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation.forYouEntry
@ -92,7 +92,7 @@ fun NiaApp(
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val shouldShowGradientBackground = appState.currentNavBarItem == FOR_YOU
val shouldShowGradientBackground = appState.currentTopLevelNavItem == FOR_YOU
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
NiaBackground(modifier = modifier) {
@ -145,7 +145,7 @@ internal fun NiaApp(
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources
val unreadRoutes by appState.topLevelRoutesWithUnreadResources
.collectAsStateWithLifecycle()
if (showSettingsDialog) {
@ -160,25 +160,25 @@ internal fun NiaApp(
NiaNavigationSuiteScaffold(
navigationSuiteItems = {
TOP_LEVEL_ROUTES.forEach { (navKey, navBarItem) ->
val hasUnread = unreadDestinations.contains(navBarItem)
val selected = navKey == appState.navigationState.topLevelRoute
TOP_LEVEL_ROUTES.forEach { (route, navItem) ->
val hasUnread = unreadRoutes.contains(route)
val selected = route == appState.navigationState.currentTopLevelKey
item(
selected = selected,
onClick = { navigator.navigate(navKey) },
onClick = { navigator.navigate(route) },
icon = {
Icon(
imageVector = navBarItem.unselectedIcon,
imageVector = navItem.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = navBarItem.selectedIcon,
imageVector = navItem.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(navBarItem.iconTextId)) },
label = { Text(stringResource(navItem.iconTextId)) },
modifier = Modifier
.testTag("NiaNavItem")
.then(if (hasUnread) Modifier.notificationDot() else Modifier),
@ -217,7 +217,7 @@ internal fun NiaApp(
),
) {
// Show the top app bar on top level destinations.
val destination = appState.currentNavBarItem
val destination = appState.currentTopLevelNavItem
var shouldShowTopAppBar = false
if (destination != null) {

@ -20,16 +20,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.navigation.simple.NavigationState
import com.google.samples.apps.nowinandroid.core.navigation.simple.rememberNavigationState
import com.google.samples.apps.nowinandroid.core.navigation.NavigationState
import com.google.samples.apps.nowinandroid.core.navigation.rememberNavigationState
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.navigation.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_ROUTES
import com.google.samples.apps.nowinandroid.navigation.NavBarItem
import com.google.samples.apps.nowinandroid.navigation.TopLevelNavItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -75,8 +75,9 @@ class NiaAppState(
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
) {
val currentNavBarItem: NavBarItem?
@Composable get() = TOP_LEVEL_ROUTES[navigationState.topLevelRoute]
// TODO: I think this should return null if the current route is not a topLevelRoute
val currentTopLevelNavItem: TopLevelNavItem?
@Composable get() = TOP_LEVEL_ROUTES[navigationState.currentTopLevelKey]
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
@ -87,14 +88,14 @@ class NiaAppState(
)
/**
* The top level destinations that have unread news resources.
* The top level routes that have unread news resources.
*/
val topLevelDestinationsWithUnreadResources: StateFlow<Set<NavBarItem>> =
val topLevelRoutesWithUnreadResources: StateFlow<Set<NavKey>> =
userNewsResourceRepository.observeAllForFollowedTopics()
.combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->
setOfNotNull(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
ForYouRoute.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BookmarksRoute.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}
.stateIn(

@ -1,253 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.core.navigation
import androidx.compose.runtime.remember
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.testing.ViewModelScenario
import androidx.lifecycle.viewmodel.testing.viewModelScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class NiaBackStackViewModelTest {
@get:Rule val rule = createComposeRule()
private val serializersModules = SerializersModule {
polymorphic(NiaNavKey::class) {
subclass(TestStartKey::class, TestStartKey.serializer())
subclass(TestTopLevelKeyFirst::class, TestTopLevelKeyFirst.serializer())
subclass(TestTopLevelKeySecond::class, TestTopLevelKeySecond.serializer())
subclass(TestKeyFirst::class, TestKeyFirst.serializer())
subclass(TestKeySecond::class, TestKeySecond.serializer())
}
}
private fun createViewModel() = NiaBackStackViewModel(
savedStateHandle = SavedStateHandle(),
niaNavigationState = NiaNavigationState(TestStartKey),
serializersModules = serializersModules,
)
@Test
fun testStartKeySaved() {
rule.setContent {
val viewModel = createViewModel()
assertThat(viewModel.backStackMap.size).isEqualTo(1)
val entry = viewModel.backStackMap[TestStartKey]
assertThat(entry).isNotNull()
assertThat(entry).containsExactly(TestStartKey)
}
}
@Test
fun testNonTopLevelKeySaved() {
val viewModel = createViewModel()
rule.setContent {
val navigator = remember { NiaNavigator(viewModel.niaNavigationState) }
navigator.navigate(TestKeyFirst)
}
assertThat(viewModel.backStackMap.size).isEqualTo(1)
val entry = viewModel.backStackMap[TestStartKey]
assertThat(entry).isNotNull()
assertThat(entry).containsExactly(TestStartKey, TestKeyFirst).inOrder()
}
@Test
fun testTopLevelKeySaved() {
val viewModel = createViewModel()
rule.setContent {
val navigator = remember { NiaNavigator(viewModel.niaNavigationState) }
navigator.navigate(TestKeyFirst)
navigator.navigate(TestTopLevelKeyFirst)
}
assertThat(viewModel.backStackMap.size).isEqualTo(2)
val entry = viewModel.backStackMap[TestStartKey]
assertThat(entry).isNotNull()
assertThat(entry).containsExactly(TestStartKey, TestKeyFirst).inOrder()
val entry2 = viewModel.backStackMap[TestTopLevelKeyFirst]
assertThat(entry2).isNotNull()
assertThat(entry2).containsExactly(TestTopLevelKeyFirst)
}
@Test
fun testMultiStacksSaved() {
val viewModel = createViewModel()
rule.setContent {
val navigator = remember { NiaNavigator(viewModel.niaNavigationState) }
navigator.navigate(TestKeyFirst)
navigator.navigate(TestTopLevelKeyFirst)
navigator.navigate(TestKeySecond)
}
assertThat(viewModel.backStackMap.size).isEqualTo(2)
val entry = viewModel.backStackMap[TestStartKey]
assertThat(entry).isNotNull()
assertThat(entry).containsExactly(TestStartKey, TestKeyFirst).inOrder()
val entry2 = viewModel.backStackMap[TestTopLevelKeyFirst]
assertThat(entry2).isNotNull()
assertThat(entry2).containsExactly(TestTopLevelKeyFirst, TestKeySecond).inOrder()
}
@Test
fun testPopSaved() {
val viewModel = createViewModel()
rule.setContent {
val navigator = remember { NiaNavigator(viewModel.niaNavigationState) }
navigator.navigate(TestKeyFirst)
assertThat(viewModel.backStackMap.size).isEqualTo(1)
val entry = viewModel.backStackMap[TestStartKey]
assertThat(entry).isNotNull()
assertThat(entry).containsExactly(TestStartKey, TestKeyFirst).inOrder()
navigator.pop()
assertThat(viewModel.backStackMap.size).isEqualTo(1)
val entry2 = viewModel.backStackMap[TestStartKey]
assertThat(entry2).isNotNull()
assertThat(entry2).containsExactly(TestStartKey).inOrder()
}
}
@Test
fun testRestore() {
lateinit var scenario: ViewModelScenario<NiaBackStackViewModel>
lateinit var navigator: NiaNavigator
lateinit var navigatorState: NiaNavigationState
rule.setContent {
navigatorState = remember { NiaNavigationState(TestStartKey) }
navigator = remember { NiaNavigator(navigatorState) }
scenario = viewModelScenario {
NiaBackStackViewModel(
savedStateHandle = createSavedStateHandle(),
niaNavigationState = navigatorState,
serializersModules = serializersModules,
)
}
}
rule.runOnIdle {
navigator.navigate(TestKeyFirst)
assertThat(navigatorState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
}
scenario.recreate()
rule.runOnIdle {
assertThat(navigatorState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
}
scenario.close()
}
@Test
fun testRestoreMultiStacks() {
lateinit var scenario: ViewModelScenario<NiaBackStackViewModel>
lateinit var navigator: NiaNavigator
lateinit var navigatorState: NiaNavigationState
rule.setContent {
navigatorState = remember { NiaNavigationState(TestStartKey) }
navigator = remember { NiaNavigator(navigatorState) }
scenario = viewModelScenario {
NiaBackStackViewModel(
savedStateHandle = createSavedStateHandle(),
niaNavigationState = navigatorState,
serializersModules = serializersModules,
)
}
}
rule.runOnIdle {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestTopLevelKeyFirst)
navigator.navigate(TestKeySecond)
assertThat(navigatorState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKeyFirst,
TestKeySecond,
).inOrder()
}
scenario.recreate()
rule.runOnIdle {
assertThat(navigatorState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKeyFirst,
TestKeySecond,
).inOrder()
}
scenario.close()
}
}
@Serializable
private object TestStartKey : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
@Serializable
private object TestTopLevelKeyFirst : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
@Serializable
private object TestTopLevelKeySecond : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
@Serializable
private object TestKeyFirst : NiaNavKey {
override val isTopLevel: Boolean
get() = false
}
@Serializable
private object TestKeySecond : NiaNavKey {
override val isTopLevel: Boolean
get() = false
}

@ -5,7 +5,7 @@
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@ -14,15 +14,13 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.navigation.simple
package com.google.samples.apps.nowinandroid.core.navigation
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.navigation3.runtime.NavBackStack
@ -31,32 +29,24 @@ import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer
/**
* Create a navigation state that persists config changes and process death.
*/
@Composable
fun rememberNavigationState(
startRoute: NavKey,
topLevelRoutes: Set<NavKey>
startKey: NavKey,
topLevelKeys: Set<NavKey>
): NavigationState {
val topLevelRoute = rememberSerializable(
startRoute, topLevelRoutes,
serializer = MutableStateSerializer(NavKeySerializer())
) {
mutableStateOf(startRoute)
}
val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }
val topLevelStack = rememberNavBackStack(startKey)
val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) }
return remember(startRoute, topLevelRoutes) {
return remember(startKey, topLevelKeys) {
NavigationState(
startRoute = startRoute,
topLevelRoute = topLevelRoute,
backStacks = backStacks
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks
)
}
}
@ -64,23 +54,37 @@ fun rememberNavigationState(
/**
* State holder for navigation state.
*
* @param startRoute - the start route. The user will exit the app through this route.
* @param topLevelRoute - the current top level route
* @param backStacks - the back stacks for each top level route
* @param startKey - the starting navigation key. The user will exit the app through this key.
* @param currentTopLevelKey - the current top level key
* @param subStacks - the back stacks for each top level key
*/
class NavigationState(
val startRoute: NavKey,
topLevelRoute: MutableState<NavKey>,
val backStacks: Map<NavKey, NavBackStack<NavKey>>
val startKey: NavKey,
val topLevelStack: NavBackStack<NavKey>,
val subStacks: Map<NavKey, NavBackStack<NavKey>>
) {
var topLevelRoute: NavKey by topLevelRoute
val stacksInUse: List<NavKey>
get() = if (topLevelRoute == startRoute) {
val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() }
val topLevelKeys
get() = subStacks.keys
@get:VisibleForTesting
val currentSubStack : NavBackStack<NavKey>
get() = subStacks[currentTopLevelKey]
?: error("Sub stack for $currentTopLevelKey does not exist")
@get:VisibleForTesting
val currentKey
get() = currentSubStack?.last()
// TODO: Fix this
/*val stacksInUse: List<NavKey>
get() = topLevel
listOf(startRoute)
} else {
listOf(startRoute, topLevelRoute)
}
*/
}
/**
@ -91,7 +95,7 @@ fun NavigationState.toEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>
): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = backStacks.mapValues { (_, stack) ->
val decoratedEntries = subStacks.mapValues { (_, stack) ->
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
)
@ -102,7 +106,7 @@ fun NavigationState.toEntries(
)
}
return stacksInUse
return topLevelStack
.flatMap { decoratedEntries[it] ?: emptyList() }
.toMutableStateList()
}

@ -0,0 +1,91 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.core.navigation
import androidx.navigation3.runtime.NavKey
/**
* Handles navigation events (forward and back) by updating the navigation state.
*
* @param state - The navigation state that will be updated in response to navigation events.
*/
class Navigator(val state: NavigationState) {
/**
* Navigate to a navigation key
*
* @param key - the navigation key to navigate to.
*/
fun navigate(key: NavKey) {
when (key) {
state.currentTopLevelKey -> goUp()
in state.topLevelKeys -> goToTopLevel(key)
else -> goToKey(key)
}
}
/**
* Go back to the previous navigation key.
*/
fun goBack() {
when (state.currentKey){
state.startKey -> error("You cannot go back from the start route")
state.currentTopLevelKey -> {
// We're at the base of the current sub stack, go back to the previous top level
// stack.
state.topLevelStack.removeLastOrNull()
}
else -> state.currentSubStack.removeLastOrNull()
}
}
/**
* Go to a non top level key.
*/
private fun goToKey(key: NavKey) {
state.currentSubStack.apply {
// Remove it if it's already in the stack so it's added at the end.
remove(key)
add(key)
}
}
/**
* Go to a top level stack.
*/
private fun goToTopLevel(key: NavKey) {
state.topLevelStack.apply {
if (key == state.startKey) {
// This is the start key. Clear the stack so it's added as the only key.
clear()
} else {
// Remove it if it's already in the stack so it's added at the end.
remove(key)
}
add(key)
}
}
/**
* Go up in the current sub stack by clearing all but the root key.
*/
private fun goUp() {
state.currentSubStack.run {
if (size > 1) subList(1, size).clear()
}
}
}

@ -1,90 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.core.navigation
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.serialization.saved
import androidx.lifecycle.viewModelScope
import androidx.savedstate.serialization.SavedStateConfiguration
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.serializer
import javax.inject.Inject
/**
* TODO: I'm not sure why this needs to be a ViewModel - why can't it be a plain state holder that
* is scoped to `NiaAppState`?
* https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/multiplestacks/NavigationState.kt#L71
*
*/
/*
@HiltViewModel
class NiaBackStackViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
val niaNavigationState: NiaNavigationState,
serializersModules: SerializersModule,
) : ViewModel() {
private val config = SavedStateConfiguration { serializersModule = serializersModules }
@VisibleForTesting
internal var backStackMap by savedStateHandle.saved(
serializer = MapSerializer(
serializer<NiaNavKey>(),
serializer<List<NiaNavKey>>(),
),
configuration = config,
) {
linkedMapOf()
}
@VisibleForTesting
internal var activeTopLeveLKeys by savedStateHandle.saved(
serializer = ListSerializer(serializer<NiaNavKey>()),
configuration = config,
) {
listOf()
}
init {
if (backStackMap.isNotEmpty()) {
// Restore backstack from saved state handle if not empty
@Suppress("UNCHECKED_CAST")
niaNavigationState.restore(
activeTopLeveLKeys,
backStackMap as LinkedHashMap<NiaNavKey, SnapshotStateList<NiaNavKey>>,
)
}
// Start observing changes to the backStack and save backStack whenever it updates
viewModelScope.launch {
snapshotFlow {
activeTopLeveLKeys = niaNavigationState.activeTopLeveLKeys.toList()
backStackMap = niaNavigationState.backStacks
}.collect()
}
}
}
*/

@ -1,173 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.core.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import org.jetbrains.annotations.VisibleForTesting
import javax.inject.Inject
import kotlin.collections.plus
// TODO: Consider changing this to `NiaNavigationState`
class NiaNavigationState(
internal val startKey: NiaNavKey,
) {
internal var backStacks: MutableMap<NiaNavKey, SnapshotStateList<NiaNavKey>> =
linkedMapOf(
startKey to mutableStateListOf(startKey),
)
val activeTopLeveLKeys: SnapshotStateList<NiaNavKey> = mutableStateListOf(startKey)
var currentTopLevelKey: NiaNavKey by mutableStateOf(activeTopLeveLKeys.last())
private set
@get:VisibleForTesting
val currentBackStack: List<NiaNavKey>
get() = activeTopLeveLKeys.fold(mutableListOf()) { list, topLevelKey ->
list.apply {
addAll(backStacks[topLevelKey] ?: error("No back stack found for $topLevelKey"))
}
}
@get:VisibleForTesting
val currentKey: NiaNavKey
get() = backStacks[currentTopLevelKey]!!.last()
internal fun updateActiveTopLevelKeys(activeKeys: List<NiaNavKey>) {
check(activeKeys.isNotEmpty()) { "List of active top-level keys should not be empty" }
activeTopLeveLKeys.clear()
activeTopLeveLKeys.addAll(activeKeys)
currentTopLevelKey = activeTopLeveLKeys.last()
}
internal fun restore(activeKeys: List<NiaNavKey>, map: LinkedHashMap<NiaNavKey, SnapshotStateList<NiaNavKey>>?) {
map ?: return
backStacks.clear()
map.forEach { entry ->
backStacks[entry.key] = entry.value.toMutableStateList()
}
updateActiveTopLevelKeys(activeKeys)
}
}
/**
* TODO: Document this
*/
class NiaNavigator @Inject constructor(
val niaNavigationState: NiaNavigationState,
) {
// TODO: I wonder if it'd be simpler to have separate methods
// for navigating to a graph and navigating to a key. If the key is on a separate graph then
// navigate to that graph first.
fun navigate(key: NiaNavKey) {
val currentActiveSubStacks = linkedSetOf<NiaNavKey>()
niaNavigationState.apply {
currentActiveSubStacks.addAll(activeTopLeveLKeys)
when {
// top level singleTop -> clear substack
key == currentTopLevelKey -> {
backStacks[key] = mutableStateListOf(key)
// no change to currentActiveTabs
}
// top level non-singleTop
key.isTopLevel -> {
// if navigating back to start destination, then only show the starting substack
if (key == startKey) {
currentActiveSubStacks.clear()
currentActiveSubStacks.add(key)
} else {
// else either restore an existing substack or initiate new one
backStacks[key] = backStacks.remove(key) ?: mutableStateListOf(key)
// move this top level key to the top of active substacks
currentActiveSubStacks.remove(key)
currentActiveSubStacks.add(key)
}
}
// not top level - add to current substack
else -> {
val currentStack = backStacks[currentTopLevelKey]!!
// single top
if (currentStack.lastOrNull() == key) {
currentStack.removeLastOrNull()
}
currentStack.add(key)
// no change to currentActiveTabs
}
}
updateActiveTopLevelKeys(currentActiveSubStacks.toList())
}
}
fun pop() {
niaNavigationState.apply {
val currentSubstack = backStacks[currentTopLevelKey]!!
if (currentSubstack.size == 1) {
// if current sub-stack only has one key, remove the sub-stack from the map
currentSubstack.removeLastOrNull()
backStacks.remove(currentTopLevelKey)
updateActiveTopLevelKeys(activeTopLeveLKeys.dropLast(1))
} else {
currentSubstack.removeLastOrNull()
}
}
}
}
// TODO: I wonder if removing this would remove the need for the serializers modules
interface NiaNavKey {
val isTopLevel: Boolean
}
/**
* Convert the navigation state to `NavEntry`s that can be displayed by a `NavDisplay`
*/
@Composable
fun NiaNavigationState.toEntries(
// TODO: Might be better to pass this in fully constructed
entryProviderBuilders: Set<EntryProviderScope<NiaNavKey>.() -> Unit>,
): List<NavEntry<NiaNavKey>> =
activeTopLeveLKeys.fold(emptyList()) { entries, topLevelKey ->
val decorated = key(topLevelKey) {
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator<NiaNavKey>(),
)
rememberDecoratedNavEntries(
backStack = backStacks[topLevelKey]!!,
entryDecorators = decorators,
entryProvider = entryProvider {
entryProviderBuilders.forEach { builder ->
builder()
}
},
)
}
entries + decorated
}

@ -1,46 +0,0 @@
/*
* Copyright 2025 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
*
* http://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.google.samples.apps.nowinandroid.core.navigation.simple
import androidx.navigation3.runtime.NavKey
/**
* Handles navigation events (forward and back) by updating the navigation state.
*/
class Navigator(val state: NavigationState){
fun navigate(route: NavKey){
if (route in state.backStacks.keys){
// This is a top level route, just switch to it
state.topLevelRoute = route
} else {
state.backStacks[state.topLevelRoute]?.add(route)
}
}
fun goBack(){
val currentStack = state.backStacks[state.topLevelRoute] ?:
error("Stack for ${state.topLevelRoute} not found")
val currentRoute = currentStack.last()
// If we're at the base of the current route, go back to the start route stack.
if (currentRoute == state.topLevelRoute){
state.topLevelRoute = state.startRoute
} else {
currentStack.removeLastOrNull()
}
}
}

@ -0,0 +1,260 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.core.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
private object TestFirstTopLevelKey : NavKey
private object TestSecondTopLevelKey : NavKey
private object TestThirdTopLevelKey : NavKey
private object TestKeyFirst : NavKey
private object TestKeySecond : NavKey
class NavigatorTest {
private lateinit var navigationState: NavigationState
private lateinit var navigator: Navigator
@Before
fun setup() {
val startKey = TestFirstTopLevelKey
val topLevelStack = NavBackStack<NavKey>(startKey)
val topLevelKeys = listOf(
startKey,
TestSecondTopLevelKey,
TestThirdTopLevelKey
)
val subStacks = topLevelKeys.associateWith { key -> NavBackStack(key) }
navigationState = NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks
)
navigator = Navigator(navigationState)
}
@Test
fun testStartKey() {
assertThat(navigationState.startKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testNavigate() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.subStacks[TestFirstTopLevelKey]?.last()).isEqualTo(TestKeyFirst)
}
@Test
fun testNavigateTopLevel() {
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
}
@Test
fun testNavigateSingleTop() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
}
@Test
fun testNavigateTopLevelSingleTop() {
navigator.navigate(TestSecondTopLevelKey)
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
TestKeyFirst,
).inOrder()
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
).inOrder()
}
@Test
fun testSubStack() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testMultiStack() {
// add to start stack
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
// navigate to new top level
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// add to new stack
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// go back to start stack
navigator.navigate(TestFirstTopLevelKey)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testPopOneNonTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testPopOneTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// remove TopLevel
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun popMultipleNonTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
navigator.goBack()
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun popMultipleTopLevel() {
// second sub-stack
navigator.navigate(TestSecondTopLevelKey)
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
TestKeyFirst,
).inOrder()
// third sub-stack
navigator.navigate(TestThirdTopLevelKey)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestThirdTopLevelKey,
TestKeySecond,
).inOrder()
repeat(4) {
navigator.goBack()
}
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun throwOnEmptyBackStack() {
assertFailsWith<IllegalStateException> {
navigator.goBack()
}
}
}

@ -1,287 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.core.navigation
import androidx.compose.runtime.mutableStateListOf
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
class NiaNavigatorStateTest {
private lateinit var niaNavigationState: NiaNavigationState
private lateinit var niaNavigator: NiaNavigator
@Before
fun setup() {
niaNavigationState = NiaNavigationState(TestStartKey)
niaNavigator = NiaNavigator(niaNavigationState)
}
@Test
fun testStartKey() {
assertThat(niaNavigationState.currentKey).isEqualTo(TestStartKey)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testNavigate() {
niaNavigator.navigate(TestKeyFirst)
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testNavigateTopLevel() {
niaNavigator.navigate(TestTopLevelKey)
assertThat(niaNavigationState.currentKey).isEqualTo(TestTopLevelKey)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestTopLevelKey)
}
@Test
fun testNavigateSingleTop() {
niaNavigator.navigate(TestKeyFirst)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
niaNavigator.navigate(TestKeyFirst)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
}
@Test
fun testNavigateTopLevelSingleTop() {
niaNavigator.navigate(TestTopLevelKey)
niaNavigator.navigate(TestKeyFirst)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestTopLevelKey,
TestKeyFirst,
).inOrder()
niaNavigator.navigate(TestTopLevelKey)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestTopLevelKey,
).inOrder()
}
@Test
fun testSubStack() {
niaNavigator.navigate(TestKeyFirst)
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
niaNavigator.navigate(TestKeySecond)
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testMultiStack() {
// add to start stack
niaNavigator.navigate(TestKeyFirst)
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
// navigate to new top level
niaNavigator.navigate(TestTopLevelKey)
assertThat(niaNavigationState.currentKey).isEqualTo(TestTopLevelKey)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestTopLevelKey)
// add to new stack
niaNavigator.navigate(TestKeySecond)
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestTopLevelKey)
// go back to start stack
niaNavigator.navigate(TestStartKey)
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testRestore() {
assertThat(niaNavigationState.currentBackStack).containsExactly(TestStartKey)
niaNavigationState.restore(
listOf(TestStartKey, TestTopLevelKey),
linkedMapOf(
TestStartKey to mutableStateListOf(TestStartKey, TestKeyFirst),
TestTopLevelKey to mutableStateListOf(TestTopLevelKey, TestKeySecond),
),
)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKey,
TestKeySecond,
).inOrder()
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestTopLevelKey)
}
@Test
fun testPopOneNonTopLevel() {
niaNavigator.navigate(TestKeyFirst)
niaNavigator.navigate(TestKeySecond)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
niaNavigator.pop()
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testPopOneTopLevel() {
niaNavigator.navigate(TestKeyFirst)
niaNavigator.navigate(TestTopLevelKey)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKey,
).inOrder()
assertThat(niaNavigationState.currentKey).isEqualTo(TestTopLevelKey)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestTopLevelKey)
// remove TopLevel
niaNavigator.pop()
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
assertThat(niaNavigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun popMultipleNonTopLevel() {
niaNavigator.navigate(TestKeyFirst)
niaNavigator.navigate(TestKeySecond)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
niaNavigator.pop()
niaNavigator.pop()
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
).inOrder()
assertThat(niaNavigationState.currentKey).isEqualTo(TestStartKey)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun popMultipleTopLevel() {
val testTopLevelKeyTwo = object : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
// second sub-stack
niaNavigator.navigate(TestTopLevelKey)
niaNavigator.navigate(TestKeyFirst)
// third sub-stack
niaNavigator.navigate(testTopLevelKeyTwo)
niaNavigator.navigate(TestKeySecond)
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
TestTopLevelKey,
TestKeyFirst,
testTopLevelKeyTwo,
TestKeySecond,
).inOrder()
repeat(4) {
niaNavigator.pop()
}
assertThat(niaNavigationState.currentBackStack).containsExactly(
TestStartKey,
).inOrder()
assertThat(niaNavigationState.currentKey).isEqualTo(TestStartKey)
assertThat(niaNavigationState.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun throwOnEmptyBackStack() {
assertFailsWith<IllegalStateException> {
niaNavigator.pop()
}
}
}
private object TestStartKey : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
private object TestTopLevelKey : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
private object TestKeyFirst : NiaNavKey {
override val isTopLevel: Boolean
get() = false
}
private object TestKeySecond : NiaNavKey {
override val isTopLevel: Boolean
get() = false
}

@ -22,33 +22,16 @@ import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.simple.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
//import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object BookmarksEntryProvider {
@Provides
@IntoSet
fun provideBookmarksEntryProviderBuilder(
navigator: Navigator,
): EntryProviderScope<NavKey>.() -> Unit = { bookmarksEntry(navigator) }
}
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.bookmarksEntry(navigator: Navigator) {
entry<BookmarksRoute> {
val snackbarHostState = LocalSnackbarHostState.current
BookmarksScreen(
onTopicClick = { topicId -> navigator.navigate(TopicRoute(topicId)) },
onTopicClick = navigator::navigateToTopic,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,

@ -1,42 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.serialization.modules.PolymorphicModuleBuilder
/**
* Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer
*
*/
/*
@Module
@InstallIn(SingletonComponent::class)
object BookmarksSerializerModule {
@Provides
@IntoSet
fun provideBookmarksPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = {
subclass(BookmarksRoute::class, BookmarksRoute.serializer())
}
}
*/

@ -18,35 +18,15 @@ package com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.simple.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
//import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object ForYouEntryProvider {
/**
* The ForYou composable for the app. It can also display information about topics.
* This should be supplied from a separate module.
*/
@Provides
@IntoSet
fun provideForYouEntryProviderBuilder(
navigator: Navigator,
): EntryProviderScope<NavKey>.() -> Unit = { forYouEntry(navigator) }
}
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.forYouEntry(navigator: Navigator) {
entry<ForYouRoute> {
ForYouScreen(
onTopicClick = { topicId -> navigator.navigate(TopicRoute(topicId)) },
onTopicClick = navigator::navigateToTopic,
)
}
}

@ -1,42 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.feature.foryou.impl.navigation
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.serialization.modules.PolymorphicModuleBuilder
/**
* Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer
*
*/
/*
@Module
@InstallIn(SingletonComponent::class)
object ForYouRouteSerializerModule {
@Provides
@IntoSet
fun provideForYouPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = {
subclass(ForYouRoute::class, ForYouRoute.serializer())
}
}
*/

@ -21,12 +21,13 @@ import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.simple.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsScreen
import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
//import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import dagger.Module
import dagger.Provides
@ -57,9 +58,8 @@ fun EntryProviderScope<NavKey>.interestsEntry(navigator: Navigator) {
it.create(key)
}
InterestsScreen(
// TODO: This event should be provided by the ViewModel
// TODO: This could be made into an extension function on navigator
onTopicClick = { topicId -> navigator.navigate(TopicRoute(topicId)) },
// TODO: This event should either be provided by the ViewModel or by the navigator, not both
onTopicClick = navigator::navigateToTopic,
// TODO: This should be dynamically calculated based on the rendering scene
// See https://github.com/android/nav3-recipes/commit/488f4811791ca3ed7192f4fe3c86e7371b32ebdc#diff-374e02026cdd2f68057dd940f203dc4ba7319930b33e9555c61af7e072211cabR89

@ -1,42 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.feature.interests.impl.navigation
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.serialization.modules.PolymorphicModuleBuilder
/**
* Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer
*
*/
/*
@Module
@InstallIn(SingletonComponent::class)
object InterestsSerializerModule {
@Provides
@IntoSet
fun provideInterestsPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = {
subclass(InterestsRoute::class, InterestsRoute.serializer())
}
}
*/

@ -17,21 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.search.api.navigation
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigator
import kotlinx.serialization.Serializable
/*
@Serializable
object SearchRoute : NiaNavKey {
override val isTopLevel: Boolean
get() = false
}
fun NiaNavigator.navigateToSearch() {
navigate(SearchRoute)
}
*/
@Serializable
object SearchRoute : NavKey

@ -18,34 +18,18 @@ package com.google.samples.apps.nowinandroid.feature.search.impl.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.simple.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchRoute
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object SearchEntryProvider {
@Provides
@IntoSet
fun provideSearchEntryProviderBuilder(
navigator: Navigator,
): EntryProviderScope<NavKey>.() -> Unit = { searchEntry(navigator) }
}
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.searchEntry(navigator: Navigator) {
entry<SearchRoute> { key ->
entry<SearchRoute> {
SearchScreen(
onBackClick = { navigator.goBack() },
onInterestsClick = { navigator.navigate(InterestsRoute()) },
onTopicClick = { topicId -> navigator.navigate(TopicRoute(topicId)) },
onTopicClick = navigator::navigateToTopic,
)
}
}

@ -1,40 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.feature.search.impl.navigation
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchRoute
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.serialization.modules.PolymorphicModuleBuilder
/**
* Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer
*
*/
/*@Module
@InstallIn(SingletonComponent::class)
object SearchSerializerModule {
@Provides
@IntoSet
fun provideSearchPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = {
subclass(SearchRoute::class, SearchRoute.serializer())
}
}*/

@ -17,23 +17,14 @@
package com.google.samples.apps.nowinandroid.feature.topic.api.navigation
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigator
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import kotlinx.serialization.Serializable
/*
@Serializable
data class TopicRoute(val id: String) : NiaNavKey {
override val isTopLevel: Boolean
get() = false
}
data class TopicRoute(val id: String) : NavKey
fun NiaNavigator.navigateToTopic(
fun Navigator.navigateToTopic(
topicId: String,
) {
navigate(TopicRoute(topicId))
}
*/
@Serializable
data class TopicRoute(val id: String) : NavKey

@ -1,39 +0,0 @@
/*
* Copyright 2025 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.google.samples.apps.nowinandroid.feature.topic.api.navigation
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.serialization.modules.PolymorphicModuleBuilder
/**
* Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer
*
*/
/*@Module
@InstallIn(SingletonComponent::class)
object TopicSerializerModule {
@Provides
@IntoSet
fun provideTopicPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = {
subclass(TopicRoute::class, TopicRoute.serializer())
}
}*/

@ -21,29 +21,12 @@ import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.simple.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
//import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.impl.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.impl.TopicViewModel
import com.google.samples.apps.nowinandroid.feature.topic.impl.TopicViewModel.Factory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object TopicEntryProvider {
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Provides
@IntoSet
fun provideTopicEntryProviderBuilder(
navigator: Navigator,
): EntryProviderScope<NavKey>.() -> Unit = { topicEntry(navigator) }
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun EntryProviderScope<NavKey>.topicEntry(navigator: Navigator) {
@ -54,7 +37,7 @@ fun EntryProviderScope<NavKey>.topicEntry(navigator: Navigator) {
TopicScreen(
showBackButton = true,
onBackClick = { navigator.goBack() },
onTopicClick = { topicId -> navigator.navigate(TopicRoute(topicId)) },
onTopicClick = navigator::navigateToTopic,
viewModel = hiltViewModel<TopicViewModel, Factory>(
key = id,
) { factory ->

Loading…
Cancel
Save