Refactor navigation routes to NavKeys

This commit refactors the navigation implementation by renaming all `...Route` classes to `...NavKey`. This change provides more descriptive and consistent naming for navigation keys across the codebase.

Key changes include:
*   Renamed `BookmarksRoute` to `BookmarksNavKey`
*   Renamed `ForYouRoute` to `ForYouNavKey`
*   Renamed `InterestsRoute` to `InterestsNavKey`
*   Renamed `TopicRoute` to `TopicNavKey`
*   Renamed `SearchRoute` to `SearchNavKey`
*   Updated all associated feature modules, tests, and UI components to use the new `NavKey` names.
*   Removed obsolete test utilities and mock providers related to the old navigation setup.
*   Deleted outdated dependency graph images and their corresponding `README.md` files from feature modules.
pull/2003/head
Don Turner 1 week ago
parent bd484ebb3c
commit b9a80fc038

@ -75,7 +75,6 @@ class MainActivity : ComponentActivity() {
private val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
@ -134,7 +133,6 @@ class MainActivity : ComponentActivity() {
splashScreen.setKeepOnScreenCondition { viewModel.uiState.value.shouldKeepSplashScreen() }
setContent {
val appState = rememberNiaAppState(
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,

@ -18,6 +18,14 @@ package com.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
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.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
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 navigation items in the application. Contains UI information about the
@ -35,4 +43,31 @@ data class TopLevelNavItem(
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
)
)
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_NAV_ITEMS = mapOf(
ForYouNavKey to FOR_YOU,
BookmarksNavKey to BOOKMARKS,
InterestsNavKey(null) to INTERESTS,
)

@ -1,53 +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 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_NAV_ITEMS = mapOf(
ForYouRoute to FOR_YOU,
BookmarksRoute to BOOKMARKS,
InterestsRoute(null) to INTERESTS,
)

@ -76,13 +76,13 @@ 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.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation.forYouEntry
import com.google.samples.apps.nowinandroid.feature.interests.impl.navigation.interestsEntry
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchRoute
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchNavKey
import com.google.samples.apps.nowinandroid.feature.search.impl.navigation.searchEntry
import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsDialog
import com.google.samples.apps.nowinandroid.feature.topic.impl.navigation.topicEntry
import com.google.samples.apps.nowinandroid.navigation.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS
import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR
@ -92,7 +92,7 @@ fun NiaApp(
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val shouldShowGradientBackground = appState.currentTopLevelNavItem == FOR_YOU
val shouldShowGradientBackground = appState.navigationState.currentTopLevelKey == ForYouNavKey
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
NiaBackground(modifier = modifier) {
@ -135,7 +135,8 @@ fun NiaApp(
@Composable
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class, ExperimentalMaterial3AdaptiveApi::class,
ExperimentalComposeUiApi::class,
ExperimentalMaterial3AdaptiveApi::class,
)
internal fun NiaApp(
appState: NiaAppState,
@ -145,7 +146,7 @@ internal fun NiaApp(
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val unreadRoutes by appState.topLevelRoutesWithUnreadResources
val unreadNavKeys by appState.topLevelNavKeysWithUnreadResources
.collectAsStateWithLifecycle()
if (showSettingsDialog) {
@ -161,7 +162,7 @@ internal fun NiaApp(
NiaNavigationSuiteScaffold(
navigationSuiteItems = {
TOP_LEVEL_NAV_ITEMS.forEach { (navKey, navItem) ->
val hasUnread = unreadRoutes.contains(navKey)
val hasUnread = unreadNavKeys.contains(navKey)
val selected = navKey == appState.navigationState.currentTopLevelKey
item(
selected = selected,
@ -239,7 +240,7 @@ internal fun NiaApp(
containerColor = Color.Transparent,
),
onActionClick = { onTopAppBarActionClick() },
onNavigationClick = { navigator.navigate(SearchRoute) },
onNavigationClick = { navigator.navigate(SearchNavKey) },
)
}
@ -253,7 +254,6 @@ internal fun NiaApp(
},
),
) {
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
val entryProvider = entryProvider {

@ -26,10 +26,10 @@ 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.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.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS
import com.google.samples.apps.nowinandroid.navigation.TopLevelNavItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -45,8 +45,7 @@ fun rememberNiaAppState(
timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): NiaAppState {
val navigationState = rememberNavigationState(ForYouRoute, TOP_LEVEL_NAV_ITEMS.keys)
val navigationState = rememberNavigationState(ForYouNavKey, TOP_LEVEL_NAV_ITEMS.keys)
NavigationTrackingSideEffect(navigationState)
@ -75,10 +74,6 @@ class NiaAppState(
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
) {
// TODO: I think this should return null if the current route is not a topLevelRoute
val currentTopLevelNavItem: TopLevelNavItem?
@Composable get() = TOP_LEVEL_NAV_ITEMS[navigationState.currentTopLevelKey]
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
@ -88,14 +83,14 @@ class NiaAppState(
)
/**
* The top level routes that have unread news resources.
* The top level nav keys that have unread news resources.
*/
val topLevelRoutesWithUnreadResources: StateFlow<Set<NavKey>> =
val topLevelNavKeysWithUnreadResources: StateFlow<Set<NavKey>> =
userNewsResourceRepository.observeAllForFollowedTopics()
.combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->
setOfNotNull(
ForYouRoute.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BookmarksRoute.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
ForYouNavKey.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BookmarksNavKey.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}
.stateIn(
@ -115,15 +110,10 @@ class NiaAppState(
/**
* Stores information about navigation events to be used with JankStats
*/
// TODO: NavigationState needs to expose an observable representation of its state for this to work
@Composable
private fun NavigationTrackingSideEffect(navigationState: NavigationState) {
// TrackDisposableJank(niaNavigator) { metricsHolder ->
// snapshotFlow {
// val stack = niaNavigator.backStack.toList()
// metricsHolder.state?.putState("Navigation", stack.lastOrNull().toString())
// }
// onDispose { }
// }
TrackDisposableJank(navigationState.currentKey) { metricsHolder ->
metricsHolder.state?.putState("Navigation", navigationState.currentKey.toString())
onDispose {}
}
}

@ -125,11 +125,9 @@ class NiaAppScreenSizesScreenshotTests {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaNavigator = mockNiaBackStack(),
)
NiaApp(
fakeAppState,
entryProviderBuilders = MockEntryProvider,
windowAdaptiveInfo = WindowAdaptiveInfo(
windowSizeClass = WindowSizeClass.compute(
width.value,

@ -18,14 +18,17 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.remember
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation3.runtime.NavBackStack
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.navigation.NavigationState
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor
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.TOP_LEVEL_NAV_ITEMS
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.collect
@ -39,7 +42,6 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Tests [NiaAppState].
@ -63,31 +65,42 @@ class NiaAppStateTest {
// Subject under test.
private lateinit var state: NiaAppState
private fun testNavigationState() = NavigationState(
startKey = ForYouNavKey,
topLevelStack = NavBackStack(ForYouNavKey),
subStacks = mapOf(
ForYouNavKey to NavBackStack(ForYouNavKey),
BookmarksNavKey to NavBackStack(BookmarksNavKey),
),
)
@Test
fun niaAppState_currentDestination() = runTest {
val niaBackStack = mockNiaBackStack()
val navigationState = testNavigationState()
val navigator = Navigator(navigationState)
composeTestRule.setContent {
state = remember(niaBackStack) {
state = remember(navigationState) {
NiaAppState(
niaNavigator = niaBackStack,
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
navigationState = navigationState,
)
}
}
assertEquals(ForYouRoute, state.niaNavigator.currentActiveTopLevelKey)
assertEquals(ForYouRoute, state.niaNavigator.currentKey)
assertEquals(ForYouNavKey, state.navigationState.currentTopLevelKey)
assertEquals(ForYouNavKey, state.navigationState.currentKey)
// Navigate to another destination once
niaBackStack.navigate(BookmarksRoute)
navigator.navigate(BookmarksNavKey)
composeTestRule.waitForIdle()
assertEquals(BookmarksRoute, state.niaNavigator.currentActiveTopLevelKey)
assertEquals(BookmarksRoute, state.niaNavigator.currentKey)
assertEquals(BookmarksNavKey, state.navigationState.currentTopLevelKey)
assertEquals(BookmarksNavKey, state.navigationState.currentKey)
}
@Test
@ -97,14 +110,16 @@ class NiaAppStateTest {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaNavigator = mockNiaBackStack(),
)
}
assertEquals(3, TOP_LEVEL_NAV_ITEMS.size)
assertTrue(TOP_LEVEL_NAV_ITEMS[0].name.contains("for_you", true))
assertTrue(TOP_LEVEL_NAV_ITEMS[1].name.contains("bookmarks", true))
assertTrue(TOP_LEVEL_NAV_ITEMS[2].name.contains("interests", true))
val navigationState = state.navigationState
assertEquals(3, navigationState.topLevelKeys.size)
assertEquals(
setOf(ForYouNavKey, BookmarksNavKey, InterestsNavKey),
navigationState.topLevelKeys,
)
}
@Test
@ -115,7 +130,7 @@ class NiaAppStateTest {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaNavigator = mockNiaBackStack(),
navigationState = testNavigationState(),
)
}
@ -135,7 +150,7 @@ class NiaAppStateTest {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaNavigator = mockNiaBackStack(),
navigationState = testNavigationState(),
)
}
val changedTz = TimeZone.of("Europe/Prague")

@ -250,11 +250,9 @@ class SnackbarInsetsScreenshotTests {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaNavigator = mockNiaBackStack(),
)
NiaApp(
appState = appState,
entryProviderBuilders = MockEntryProvider,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},

@ -200,11 +200,9 @@ class SnackbarScreenshotTests {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaNavigator = mockNiaBackStack(),
)
NiaApp(
appState = appState,
entryProviderBuilders = MockEntryProvider,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},

@ -1,36 +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.ui
import androidx.navigation3.runtime.EntryProviderScope
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigator
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
val MockEntryProvider: Set<EntryProviderScope<NiaNavKey>.() -> Unit> =
setOf(
{
entry<ForYouRoute> {
ForYouScreen({})
}
},
)
private val startKey = ForYouRoute
fun mockNiaBackStack() = NiaNavigator(startKey)

@ -36,9 +36,8 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@Composable
fun rememberNavigationState(
startKey: NavKey,
topLevelKeys: Set<NavKey>
topLevelKeys: Set<NavKey>,
): NavigationState {
val topLevelStack = rememberNavBackStack(startKey)
val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) }
@ -46,7 +45,7 @@ fun rememberNavigationState(
NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks
subStacks = subStacks,
)
}
}
@ -61,7 +60,7 @@ fun rememberNavigationState(
class NavigationState(
val startKey: NavKey,
val topLevelStack: NavBackStack<NavKey>,
val subStacks: Map<NavKey, NavBackStack<NavKey>>
val subStacks: Map<NavKey, NavBackStack<NavKey>>,
) {
val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() }
@ -69,13 +68,12 @@ class NavigationState(
get() = subStacks.keys
@get:VisibleForTesting
val currentSubStack : NavBackStack<NavKey>
val currentSubStack: NavBackStack<NavKey>
get() = subStacks[currentTopLevelKey]
?: error("Sub stack for $currentTopLevelKey does not exist")
@get:VisibleForTesting
val currentKey
get() = currentSubStack.last()
val currentKey: NavKey by derivedStateOf { currentSubStack.last() }
}
/**
@ -83,9 +81,8 @@ class NavigationState(
*/
@Composable
fun NavigationState.toEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>
entryProvider: (NavKey) -> NavEntry<NavKey>,
): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = subStacks.mapValues { (_, stack) ->
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
@ -93,11 +90,11 @@ fun NavigationState.toEntries(
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = decorators,
entryProvider = entryProvider
entryProvider = entryProvider,
)
}
return topLevelStack
.flatMap { decoratedEntries[it] ?: emptyList() }
.toMutableStateList()
}
}

@ -42,7 +42,7 @@ class Navigator(val state: NavigationState) {
* Go back to the previous navigation key.
*/
fun goBack() {
when (state.currentKey){
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
@ -88,4 +88,4 @@ class Navigator(val state: NavigationState) {
if (size > 1) subList(1, size).clear()
}
}
}
}

@ -36,20 +36,19 @@ class NavigatorTest {
@Before
fun setup() {
val startKey = TestFirstTopLevelKey
val topLevelStack = NavBackStack<NavKey>(startKey)
val topLevelKeys = listOf(
startKey,
TestSecondTopLevelKey,
TestThirdTopLevelKey
TestThirdTopLevelKey,
)
val subStacks = topLevelKeys.associateWith { key -> NavBackStack(key) }
navigationState = NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks
subStacks = subStacks,
)
navigator = Navigator(navigationState)
}
@ -66,7 +65,6 @@ class NavigatorTest {
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.subStacks[TestFirstTopLevelKey]?.last()).isEqualTo(TestKeyFirst)
}
@Test
@ -148,7 +146,7 @@ class NavigatorTest {
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testPopOneNonTopLevel() {
navigator.navigate(TestKeyFirst)
@ -256,5 +254,3 @@ class NavigatorTest {
}
}
}

@ -1,3 +0,0 @@
# :feature:bookmarks:api module
## Dependency graph
![Dependency graph](../../../docs/images/graphs/dep_graph_feature_bookmarks_api.svg)

@ -1,3 +0,0 @@
# :feature:bookmarks:impl module
## Dependency graph
![Dependency graph](../../../docs/images/graphs/dep_graph_feature_bookmarks_impl.svg)

@ -23,12 +23,12 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
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.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.bookmarksEntry(navigator: Navigator) {
entry<BookmarksRoute> {
entry<BookmarksNavKey> {
val snackbarHostState = LocalSnackbarHostState.current
BookmarksScreen(
onTopicClick = navigator::navigateToTopic,
@ -43,6 +43,7 @@ fun EntryProviderScope<NavKey>.bookmarksEntry(navigator: Navigator) {
}
}
// TODO: Why is this here?
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("SnackbarHostState state should be initialized at runtime")
}

@ -1,3 +0,0 @@
# :feature:foryou:api module
## Dependency graph
![Dependency graph](../../../docs/images/graphs/dep_graph_feature_foryou_api.svg)

@ -20,4 +20,4 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
object ForYouRoute : NavKey
object ForYouNavKey : NavKey

@ -1,3 +0,0 @@
# :feature:foryou:impl module
## Dependency graph
![Dependency graph](../../../docs/images/graphs/dep_graph_feature_foryou_impl.svg)

@ -20,7 +20,6 @@ import android.net.Uri
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.activity.compose.ReportDrawnWhen
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -107,8 +106,7 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
@Composable
@VisibleForTesting
public fun ForYouScreen(
fun ForYouScreen(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(),

@ -19,17 +19,14 @@ 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.Navigator
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.forYouEntry(navigator: Navigator) {
entry<ForYouRoute> {
entry<ForYouNavKey> {
ForYouScreen(
onTopicClick = navigator::navigateToTopic,
)
}
}

@ -50,7 +50,7 @@ import java.util.TimeZone
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [35])
@Config(application = HiltTestApplication::class)
@LooperMode(LooperMode.Mode.PAUSED)
class ForYouScreenScreenshotTests {

@ -20,7 +20,7 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
data class InterestsRoute(
data class InterestsNavKey(
// The ID of the topic which will be initially selected at this destination
val initialTopicId: String? = null,
) : NavKey

@ -23,7 +23,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -40,7 +40,7 @@ class InterestsViewModel @AssistedInject constructor(
val userDataRepository: UserDataRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
// TODO: see comment below
@Assisted val key: InterestsRoute,
@Assisted val key: InterestsNavKey,
) : ViewModel() {
// TODO: this should no longer be necessary, the currently selected topic should be
@ -77,7 +77,7 @@ class InterestsViewModel @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(key: InterestsRoute): InterestsViewModel
fun create(key: InterestsNavKey): InterestsViewModel
}
}

@ -22,34 +22,15 @@ 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.Navigator
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
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
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object InterestsEntryProvider {
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Provides
@IntoSet
fun provideInterestsEntryProviderBuilder(
navigator: Navigator,
): EntryProviderScope<NavKey>.() -> Unit = { interestsEntry(navigator) }
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun EntryProviderScope<NavKey>.interestsEntry(navigator: Navigator) {
entry<InterestsRoute>(
entry<InterestsNavKey>(
metadata = ListDetailSceneStrategy.listPane {
InterestsDetailPlaceholder()
},

@ -18,10 +18,10 @@
package com.google.samples.apps.nowinandroid.interests.impl
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@ -29,35 +29,26 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import androidx.test.espresso.Espresso
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackViewModel
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 com.google.samples.apps.nowinandroid.core.navigation.rememberNavigationState
import com.google.samples.apps.nowinandroid.core.navigation.toEntries
import com.google.samples.apps.nowinandroid.feature.interests.api.R
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG
import com.google.samples.apps.nowinandroid.feature.interests.impl.navigation.interestsEntry
import com.google.samples.apps.nowinandroid.feature.topic.impl.navigation.topicEntry
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.modules.PolymorphicModuleBuilder
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -65,7 +56,6 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.getValue
import kotlin.properties.ReadOnlyProperty
@ -83,13 +73,6 @@ class InterestsListDetailScreenTest {
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
// entry point to get the features' hilt-injected EntryProviders that are installed in ActivityComponent
@EntryPoint
@InstallIn(ActivityComponent::class)
interface EntryProvidersEntryPoint {
fun getEntryProviders(): Set<@JvmSuppressWildcards EntryProviderScope<NiaNavKey>.() -> Unit>
}
@Inject
lateinit var topicsRepository: TopicsRepository
@ -104,15 +87,9 @@ class InterestsListDetailScreenTest {
private val Topic.testTag
get() = "topic:${this.id}"
private lateinit var entryProviderBuilders: Set<EntryProviderScope<NiaNavKey>.() -> Unit>
@Before
fun setup() {
hiltRule.inject()
composeTestRule.apply {
entryProviderBuilders = EntryPoints.get(activity, EntryProvidersEntryPoint::class.java)
.getEntryProviders()
}
}
@Test
@ -121,13 +98,7 @@ class InterestsListDetailScreenTest {
composeTestRule.apply {
setContent {
NiaTheme {
NavDisplay(
backStack = listOf<NiaNavKey>(InterestsRoute()),
sceneStrategy = rememberListDetailSceneStrategy(),
entryProvider = entryProvider {
entryProviderBuilders.forEach { it() }
},
)
TestNavDisplay()
}
}
onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed()
@ -135,19 +106,36 @@ class InterestsListDetailScreenTest {
}
}
@Composable
private fun TestNavDisplay() {
val startKey = InterestsNavKey(null)
val navigationState = rememberNavigationState(
startKey = startKey,
topLevelKeys = setOf(startKey),
)
val navigator = Navigator(navigationState)
val entryProvider = entryProvider {
interestsEntry(navigator)
topicEntry(navigator)
}
NavDisplay(
entries = navigationState.toEntries(entryProvider),
onBack = { navigator.goBack() },
sceneStrategy = rememberListDetailSceneStrategy(),
)
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_initialState_showsListPane() {
composeTestRule.apply {
setContent {
NiaTheme {
NavDisplay(
backStack = listOf<NiaNavKey>(InterestsRoute()),
sceneStrategy = rememberListDetailSceneStrategy(),
entryProvider = entryProvider {
entryProviderBuilders.forEach { it() }
},
)
TestNavDisplay()
}
}
@ -161,17 +149,8 @@ class InterestsListDetailScreenTest {
fun expandedWidth_topicSelected_updatesDetailPane() {
composeTestRule.apply {
setContent {
val backStackViewModel by composeTestRule.activity.viewModels<NiaBackStackViewModel>()
// TODO: This is broken
val backStack = backStackViewModel.niaNavigator.backStack
NiaTheme {
NavDisplay(
backStack = backStack,
sceneStrategy = rememberListDetailSceneStrategy(),
entryProvider = entryProvider {
entryProviderBuilders.forEach { it() }
},
)
TestNavDisplay()
}
}
val firstTopic = getTopics().first()
@ -189,16 +168,8 @@ class InterestsListDetailScreenTest {
fun compactWidth_topicSelected_showsTopicDetailPane() {
composeTestRule.apply {
setContent {
val backStackViewModel by composeTestRule.activity.viewModels<NiaBackStackViewModel>()
val backStack = backStackViewModel.niaNavigator.backStack
NiaTheme {
NavDisplay(
backStack = backStack,
sceneStrategy = rememberListDetailSceneStrategy(),
entryProvider = entryProvider {
entryProviderBuilders.forEach { it() }
},
)
TestNavDisplay()
}
}
@ -216,16 +187,8 @@ class InterestsListDetailScreenTest {
fun compactWidth_backPressFromTopicDetail_showsListPane() {
composeTestRule.apply {
setContent {
val backStackViewModel by composeTestRule.activity.viewModels<NiaBackStackViewModel>()
val backStack = backStackViewModel.niaNavigator.backStack
NiaTheme {
NavDisplay(
backStack = backStack,
sceneStrategy = rememberListDetailSceneStrategy(),
entryProvider = entryProvider {
entryProviderBuilders.forEach { it() }
},
)
TestNavDisplay()
}
}
@ -246,22 +209,3 @@ private fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }
@Module
@InstallIn(SingletonComponent::class)
object BackStackProvider {
@Provides
@Singleton
fun provideNiaBackStack(): NiaNavigator =
NiaNavigator(startKey = InterestsRoute())
@Provides
@Singleton
fun provideSerializersModule(
polymorphicModuleBuilders: Set<@JvmSuppressWildcards PolymorphicModuleBuilder<NiaNavKey>.() -> Unit>,
): SerializersModule = SerializersModule {
polymorphic(NiaNavKey::class) {
polymorphicModuleBuilders.forEach { it() }
}
}
}

@ -24,7 +24,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsViewModel
import kotlinx.coroutines.flow.collect
@ -68,11 +68,11 @@ class InterestsViewModelTest {
fun setup() {
viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(
route = InterestsRoute(initialTopicId = testInputTopics[0].topic.id),
route = InterestsNavKey(initialTopicId = testInputTopics[0].topic.id),
),
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
InterestsRoute(initialTopicId = testInputTopics[0].topic.id),
InterestsNavKey(initialTopicId = testInputTopics[0].topic.id),
)
}

@ -20,4 +20,4 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
object SearchRoute : NavKey
object SearchNavKey : NavKey

@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* Copyright 2023 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.

@ -19,16 +19,16 @@ 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.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.interests.api.navigation.InterestsNavKey
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchNavKey
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.searchEntry(navigator: Navigator) {
entry<SearchRoute> {
entry<SearchNavKey> {
SearchScreen(
onBackClick = { navigator.goBack() },
onInterestsClick = { navigator.navigate(InterestsRoute()) },
onInterestsClick = { navigator.navigate(InterestsNavKey()) },
onTopicClick = navigator::navigateToTopic,
)
}

@ -21,10 +21,10 @@ import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import kotlinx.serialization.Serializable
@Serializable
data class TopicRoute(val id: String) : NavKey
data class TopicNavKey(val id: String) : NavKey
fun Navigator.navigateToTopic(
topicId: String,
) {
navigate(TopicRoute(topicId))
navigate(TopicNavKey(topicId))
}

@ -22,7 +22,7 @@ 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.Navigator
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicNavKey
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
@ -30,7 +30,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.impl.TopicViewModel.Fa
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun EntryProviderScope<NavKey>.topicEntry(navigator: Navigator) {
entry<TopicRoute>(
entry<TopicNavKey>(
metadata = ListDetailSceneStrategy.detailPane(),
) { key ->
val id = key.id

@ -40,9 +40,6 @@ dependencyResolutionManagement {
}
}
mavenCentral()
maven {
url = uri("https://androidx.dev/snapshots/builds/14161874/artifacts/repository")
}
}
}
rootProject.name = "nowinandroid"

Loading…
Cancel
Save