Update feature ViewModels and tests

adaptive-previews-device-classes
Clara Fok 7 months ago committed by Eric Schmidt
parent c009f76d3b
commit 0ca2107f36

@ -86,7 +86,6 @@ dependencies {
implementation(projects.core.data)
implementation(projects.core.model)
implementation(projects.core.analytics)
implementation(projects.core.navigation)
implementation(projects.sync.work)
implementation(libs.androidx.activity.compose)
@ -100,10 +99,8 @@ dependencies {
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.lifecycle.viewModel.navigation3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.window.core)

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.ui.semantics.SemanticsActions.ScrollBy
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasTestTag
@ -39,11 +40,13 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@ -85,10 +88,10 @@ class NavigationTest {
// The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_title)
private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_interests)
private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_api_interests)
private val sampleTopic = "Headlines"
private val appName by composeTestRule.stringResource(R.string.app_name)
private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_title)
private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_api_title)
private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_top_app_bar_action_icon_description)
private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text)
@ -252,6 +255,9 @@ class NavigationTest {
}
}
// TODO decide if backStack should preserve previous stacks when navigating back to home tab (ForYou)
// https://github.com/android/nowinandroid/issues/1937
@Ignore
@Test
fun navigationBar_multipleBackStackInterests() {
composeTestRule.apply {
@ -261,12 +267,14 @@ class NavigationTest {
val topic = runBlocking {
topicsRepository.getTopics().first().sortedBy(Topic::name).last()
}
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name))
onNodeWithTag(LIST_PANE_TEST_TAG).performScrollToNode(hasText(topic.name))
onNodeWithText(topic.name).performClick()
// Verify the topic is still shown
onNodeWithTag("topic:${topic.id}").assertIsDisplayed()
// Switch tab
onNodeWithText(forYou).performClick()
// Come back to Interests
onNodeWithText(interests).performClick()

@ -41,9 +41,9 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
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.designsystem.theme.NiaTheme
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.ui.LocalTimeZone
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import com.google.samples.apps.nowinandroid.ui.NiaApp
import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState
import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme
@ -77,11 +77,10 @@ class MainActivity : ComponentActivity() {
lateinit var userNewsResourceRepository: UserNewsResourceRepository
private val viewModel: MainActivityViewModel by viewModels()
@Inject
lateinit var niaBackStack: NiaBackStack
private val backStackViewModel: NiaBackStackViewModel by viewModels()
@Inject
lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit>
lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaNavKey>.() -> Unit>
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
@ -145,7 +144,7 @@ class MainActivity : ComponentActivity() {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaBackStack = niaBackStack,
niaBackStack = backStackViewModel.niaBackStack,
)
val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()
@ -161,7 +160,7 @@ class MainActivity : ComponentActivity() {
) {
NiaApp(
appState,
entryProviderBuilders
entryProviderBuilders,
)
}
}

@ -17,12 +17,16 @@
package com.google.samples.apps.nowinandroid.di
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import javax.inject.Singleton
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.modules.PolymorphicModuleBuilder
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@ -31,4 +35,19 @@ object BackStackProvider {
@Singleton
fun provideNiaBackStack(): NiaBackStack =
NiaBackStack(startKey = TopLevelDestination.FOR_YOU.key)
/**
* 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() }
}
}
}

@ -19,7 +19,6 @@ 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.compose.runtime.compositionLocalOf
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entryProvider
@ -27,20 +26,20 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun NiaNavDisplay(
niaBackStack: NiaBackStack,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit>,
entryProviderBuilders: Set<EntryProviderBuilder<NiaNavKey>.() -> Unit>,
) {
val listDetailStrategy = rememberListDetailSceneStrategy<NiaBackStackKey>()
val listDetailStrategy = rememberListDetailSceneStrategy<NiaNavKey>()
NavDisplay(
backStack = niaBackStack.backStack,
sceneStrategy = listDetailStrategy,
onBack = { niaBackStack.removeLast() },
onBack = { count -> niaBackStack.popLast(count) },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
@ -52,4 +51,4 @@ fun NiaNavDisplay(
}
},
)
}
}

@ -1,72 +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.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.forYouSection
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
import com.google.samples.apps.nowinandroid.feature.interests.impl.interestsListDetailScreen
/**
* Top-level navigation graph. Navigation is organized as explained at
* https://d.android.com/jetpack/compose/nav-adaptive
*
* The navigation graph defined in this file defines the different top level routes. Navigation
* within each route is handled using state and Back Handlers.
*/
@Composable
fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = ForYouRoute,
modifier = modifier,
) {
forYouSection(
onTopicClick = navController::navigateToTopic,
) {
topicScreen(
showBackButton = true,
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
}
bookmarksScreen(
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}

@ -20,12 +20,12 @@ 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.core.navigation.NiaBackStackKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
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.impl.R as bookmarksR
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
@ -49,7 +49,7 @@ enum class TopLevelDestination(
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
val key: NiaBackStackKey
val key: NiaNavKey,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
@ -57,15 +57,15 @@ enum class TopLevelDestination(
iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name,
route = ForYouRoute::class,
key = ForYouRoute
key = ForYouRoute,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_impl_title,
titleTextId = bookmarksR.string.feature_bookmarks_impl_title,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
route = BookmarksRoute::class,
key = BookmarksRoute
key = BookmarksRoute,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
@ -73,7 +73,7 @@ enum class TopLevelDestination(
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
route = InterestsRoute::class,
key = InterestsRoute(null)
key = InterestsRoute(null),
),
}

@ -59,9 +59,6 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation3.runtime.EntryProviderBuilder
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
@ -80,6 +77,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsDialog
@ -95,7 +93,7 @@ import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR
@Composable
fun NiaApp(
appState: NiaAppState,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit>,
entryProviderBuilders: Set<EntryProviderBuilder<NiaNavKey>.() -> Unit>,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
@ -146,7 +144,7 @@ fun NiaApp(
)
internal fun NiaApp(
appState: NiaAppState,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit>,
entryProviderBuilders: Set<EntryProviderBuilder<NiaNavKey>.() -> Unit>,
showSettingsDialog: Boolean,
onSettingsDismissed: () -> Unit,
onTopAppBarActionClick: () -> Unit,
@ -157,7 +155,6 @@ internal fun NiaApp(
.collectAsStateWithLifecycle()
val currentTopLevelKey = appState.currentTopLevelDestination!!.key
if (showSettingsDialog) {
SettingsDialog(
onDismiss = { onSettingsDismissed() },
@ -173,7 +170,7 @@ internal fun NiaApp(
val selected = destination.key == currentTopLevelKey
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
onClick = { appState.niaBackStack.navigate(destination.key) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
@ -258,10 +255,6 @@ internal fun NiaApp(
},
),
) {
// NiaNavHost(
// appState = appState,
// onShowSnackbar = onShowSnackbar,
// )
NiaNavDisplay(
niaBackStack = appState.niaBackStack,
entryProviderBuilders,

@ -18,23 +18,17 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import androidx.compose.runtime.snapshotFlow
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.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestinations
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@ -51,12 +45,10 @@ fun rememberNiaAppState(
timeZoneMonitor: TimeZoneMonitor,
niaBackStack: NiaBackStack,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState {
NavigationTrackingSideEffect(navController)
NavigationTrackingSideEffect(niaBackStack)
return remember(
niaBackStack,
navController,
coroutineScope,
networkMonitor,
userNewsResourceRepository,
@ -64,7 +56,6 @@ fun rememberNiaAppState(
) {
NiaAppState(
niaBackStack = niaBackStack,
navController = navController,
coroutineScope = coroutineScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
@ -76,28 +67,11 @@ fun rememberNiaAppState(
@Stable
class NiaAppState(
val niaBackStack: NiaBackStack,
val navController: NavHostController,
coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
) {
private val previousDestination = mutableStateOf<NavDestination?>(null)
// val currentDestination: NavDestination?
// @Composable get() {
// // Collect the currentBackStackEntryFlow as a state
// val currentEntry = navController.currentBackStackEntryFlow
// .collectAsState(initial = null)
//
// // Fallback to previousDestination if currentEntry is null
// return currentEntry.value?.destination.also { destination ->
// if (destination != null) {
// previousDestination.value = destination
// }
// } ?: previousDestination.value
// }
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = TopLevelDestinations[niaBackStack.currentTopLevelKey]
@ -138,58 +112,18 @@ class NiaAppState(
SharingStarted.WhileSubscribed(5_000),
TimeZone.currentSystemDefault(),
)
/**
* UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you
* navigate to and from it.
*
* @param topLevelDestination: The destination the app needs to navigate to.
*/
fun navigateToTopLevelDestination(
topLevelDestination: TopLevelDestination,
) {
niaBackStack.navigateToTopLevelDestination(topLevelDestination.key)
// trace("Navigation: ${topLevelDestination.name}") {
// val topLevelNavOptions = navOptions {
// // Pop up to the start destination of the graph to
// // avoid building up a large stack of destinations
// // on the back stack as users select items
// popUpTo(navController.graph.findStartDestination().id) {
// saveState = true
// }
// // Avoid multiple copies of the same destination when
// // reselecting the same item
// launchSingleTop = true
// // Restore state when reselecting a previously selected item
// restoreState = true
// }
//
// when (topLevelDestination) {
// FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
// BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
// INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions)
// }
// }
}
fun navigateToSearch() = navController.navigateToSearch()
}
/**
* Stores information about navigation events to be used with JankStats
*/
@Composable
private fun NavigationTrackingSideEffect(navController: NavHostController) {
TrackDisposableJank(navController) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
metricsHolder.state?.putState("Navigation", destination.route.toString())
}
navController.addOnDestinationChangedListener(listener)
onDispose {
navController.removeOnDestinationChangedListener(listener)
private fun NavigationTrackingSideEffect(niaBackStack: NiaBackStack) {
TrackDisposableJank(niaBackStack) { metricsHolder ->
snapshotFlow {
val stack = niaBackStack.backStack.toList()
metricsHolder.state?.putState("Navigation", stack.lastOrNull().toString())
}
onDispose { }
}
}
}

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

@ -16,21 +16,15 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation.NavHostController
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable
import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
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 dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.collect
@ -70,30 +64,29 @@ class NiaAppStateTest {
@Test
fun niaAppState_currentDestination() = runTest {
var currentDestination: String? = null
val niaBackStack = mockNiaBackStack()
composeTestRule.setContent {
val navController = rememberTestNavController()
state = remember(navController) {
state = remember(niaBackStack) {
NiaAppState(
navController = navController,
niaBackStack = niaBackStack,
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
}
// Update currentDestination whenever it changes
currentDestination = state.niaBackStack.currentKey
assertEquals(ForYouRoute, state.niaBackStack.currentTopLevelKey)
assertEquals(ForYouRoute, state.niaBackStack.currentKey)
// Navigate to destination b once
LaunchedEffect(Unit) {
navController.setCurrentDestination("b")
}
}
// Navigate to another destination once
niaBackStack.navigate(BookmarksRoute)
composeTestRule.waitForIdle()
assertEquals("b", currentDestination)
assertEquals(BookmarksRoute, state.niaBackStack.currentTopLevelKey)
assertEquals(BookmarksRoute, state.niaBackStack.currentKey)
}
@Test
@ -103,6 +96,7 @@ class NiaAppStateTest {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
)
}
@ -116,11 +110,11 @@ class NiaAppStateTest {
fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
)
}
@ -136,11 +130,11 @@ class NiaAppStateTest {
fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
)
}
val changedTz = TimeZone.of("Europe/Prague")
@ -152,18 +146,3 @@ class NiaAppStateTest {
)
}
}
@Composable
private fun rememberTestNavController(): TestNavHostController {
val context = LocalContext.current
return remember {
TestNavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator())
graph = createGraph(startDestination = "a") {
composable("a") { }
composable("b") { }
composable("c") { }
}
}
}
}

@ -67,6 +67,7 @@ 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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -147,9 +148,7 @@ class SnackbarInsetsScreenshotTests {
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium_noSnackbar",
@ -159,13 +158,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -176,13 +173,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"insets_snackbar_medium_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -193,13 +188,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"insets_snackbar_expanded_expanded",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -209,17 +202,18 @@ class SnackbarInsetsScreenshotTests {
}
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) {
lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) {
scope = rememberCoroutineScope()
@ -256,10 +250,11 @@ class SnackbarInsetsScreenshotTests {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
entryProviderBuilders = MockEntryProvider,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
@ -280,7 +275,7 @@ class SnackbarInsetsScreenshotTests {
}
scope.launch {
action()
action(snackbarHostState)
}
composeTestRule.onNodeWithTag("root")

@ -40,6 +40,7 @@ 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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -120,9 +121,7 @@ class SnackbarScreenshotTests {
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium_noSnackbar",
@ -132,13 +131,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -149,13 +146,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"snackbar_medium_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -166,13 +161,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"snackbar_expanded_expanded",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -182,17 +175,19 @@ class SnackbarScreenshotTests {
}
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) {
lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) {
scope = rememberCoroutineScope()
@ -205,10 +200,11 @@ class SnackbarScreenshotTests {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
entryProviderBuilders = MockEntryProvider,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
@ -227,7 +223,7 @@ class SnackbarScreenshotTests {
}
scope.launch {
action()
action(snackbarHostState)
}
composeTestRule.onRoot()

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

@ -0,0 +1,17 @@
#
# 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.
#
sdk = 35

@ -1,3 +1,4 @@
<<<<<<< HEAD
# `:benchmarks`
## Module dependency graph
@ -132,3 +133,8 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
</details>
<!--endregion-->
=======
# :benchmarks module
## Dependency graph
![Dependency graph](../docs/images/graphs/dep_graph_benchmarks.svg)
>>>>>>> a059e426 (Update readme and build dependency graph)

@ -78,9 +78,13 @@ gradlePlugin {
id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidFeature") {
id = libs.plugins.nowinandroid.android.feature.get().pluginId
implementationClass = "AndroidFeatureConventionPlugin"
register("androidFeatureImpl") {
id = libs.plugins.nowinandroid.android.feature.impl.get().pluginId
implementationClass = "AndroidFeatureImplConventionPlugin"
}
register("androidFeatureApi") {
id = libs.plugins.nowinandroid.android.feature.api.get().pluginId
implementationClass = "AndroidFeatureApiConventionPlugin"
}
register("androidLibraryJacoco") {
id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId

@ -31,5 +31,4 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
configureAndroidCompose(extension)
}
}
}

@ -0,0 +1,34 @@
/*
* 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.
*/
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureApiConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
dependencies {
"api"(project(":core:navigation"))
}
}
}
}

@ -23,12 +23,11 @@ import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureConventionPlugin : Plugin<Project> {
class AndroidFeatureImplConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "nowinandroid.hilt")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true
@ -39,15 +38,12 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
"implementation"(project(":core:ui"))
"implementation"(project(":core:designsystem"))
"implementation"(libs.findLibrary("androidx.hilt.navigation.compose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation.compose").get())
"implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation3.runtime").get())
"implementation"(libs.findLibrary("androidx.tracing.ktx").get())
"implementation"(libs.findLibrary("kotlinx.serialization.json").get())
"testImplementation"(libs.findLibrary("androidx.navigation.testing").get())
"androidTestImplementation"(
libs.findLibrary("androidx.lifecycle.runtimeTesting").get(),
)

@ -31,5 +31,4 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
configureAndroidCompose(extension)
}
}
}

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension

@ -0,0 +1,3 @@
# :core:navigation module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_navigation.svg)

@ -1,8 +1,40 @@
/*
* 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.
*/
plugins {
alias(libs.plugins.nowinandroid.jvm.library)
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.hilt)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.navigation"
}
dependencies {
api(libs.androidx.navigation3.runtime)
implementation(libs.androidx.savedstate.compose)
testImplementation(libs.truth)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.compose.ui.testManifest)
androidTestImplementation(libs.androidx.lifecycle.viewModel.testing)
androidTestImplementation(libs.truth)
}

@ -0,0 +1,233 @@
/*
* 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.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(),
niaBackStack = NiaBackStack(TestStartKey),
serializersModules = serializersModules,
)
@Test
fun testStartKeySaved() {
rule.setContent {
val viewModel = createViewModel()
assertThat(viewModel.backStackMap).containsEntry(
TestStartKey,
mutableListOf(TestStartKey),
)
}
}
@Test
fun testNonTopLevelKeySaved() {
val viewModel = createViewModel()
rule.setContent {
val backStack = viewModel.niaBackStack
backStack.navigate(TestKeyFirst)
}
assertThat(viewModel.backStackMap).containsEntry(
TestStartKey,
mutableListOf(TestStartKey, TestKeyFirst),
)
}
@Test
fun testTopLevelKeySaved() {
val viewModel = createViewModel()
rule.setContent {
val backStack = viewModel.niaBackStack
backStack.navigate(TestKeyFirst)
backStack.navigate(TestTopLevelKeyFirst)
}
assertThat(viewModel.backStackMap).containsExactly(
TestStartKey,
mutableListOf(TestStartKey, TestKeyFirst),
TestTopLevelKeyFirst,
mutableListOf(TestTopLevelKeyFirst),
).inOrder()
}
@Test
fun testMultiStacksSaved() {
val viewModel = createViewModel()
rule.setContent {
viewModel.niaBackStack.navigate(TestKeyFirst)
viewModel.niaBackStack.navigate(TestTopLevelKeyFirst)
viewModel.niaBackStack.navigate(TestKeySecond)
}
assertThat(viewModel.backStackMap).containsExactly(
TestStartKey,
mutableListOf(TestStartKey, TestKeyFirst),
TestTopLevelKeyFirst,
mutableListOf(TestTopLevelKeyFirst, TestKeySecond),
).inOrder()
}
@Test
fun testPopSaved() {
val viewModel = createViewModel()
rule.setContent {
val backStack = viewModel.niaBackStack
backStack.navigate(TestKeyFirst)
assertThat(viewModel.backStackMap).containsExactly(
TestStartKey,
mutableListOf(TestStartKey, TestKeyFirst),
)
backStack.popLast()
assertThat(viewModel.backStackMap).containsExactly(
TestStartKey,
mutableListOf(TestStartKey),
)
}
}
@Test
fun testRestore() {
lateinit var scenario: ViewModelScenario<NiaBackStackViewModel>
rule.setContent {
scenario = viewModelScenario {
NiaBackStackViewModel(
savedStateHandle = createSavedStateHandle(),
niaBackStack = NiaBackStack(TestStartKey),
serializersModules = serializersModules,
)
}
}
rule.runOnIdle {
scenario.viewModel.niaBackStack.navigate(TestKeyFirst)
assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
}
scenario.recreate()
rule.runOnIdle {
assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
}
}
@Test
fun testRestoreMultiStacks() {
lateinit var scenario: ViewModelScenario<NiaBackStackViewModel>
rule.setContent {
scenario = viewModelScenario {
NiaBackStackViewModel(
savedStateHandle = createSavedStateHandle(),
niaBackStack = NiaBackStack(TestStartKey),
serializersModules = serializersModules,
)
}
}
rule.runOnIdle {
scenario.viewModel.niaBackStack.navigate(TestKeyFirst)
scenario.viewModel.niaBackStack.navigate(TestTopLevelKeyFirst)
scenario.viewModel.niaBackStack.navigate(TestKeySecond)
assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKeyFirst,
TestKeySecond,
).inOrder()
}
scenario.recreate()
rule.runOnIdle {
assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKeyFirst,
TestKeySecond,
).inOrder()
}
}
}
@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
}

@ -21,63 +21,107 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import javax.inject.Inject
import kotlin.collections.remove
import org.jetbrains.annotations.VisibleForTesting
import kotlin.collections.mutableListOf
class NiaBackStack @Inject constructor(
startKey: NiaBackStackKey,
// TODO refine back behavior - perhaps take a lambda so that each screen / use site can customize back behavior?
// https://github.com/android/nowinandroid/issues/1934
class NiaBackStack(
private val startKey: NiaNavKey,
) {
val backStack = mutableStateListOf(startKey)
internal var backStackMap: LinkedHashMap<NiaNavKey, MutableList<NiaNavKey>> =
linkedMapOf(
startKey to mutableListOf(startKey),
)
// Maintain a stack for each top level route
private var topLevelStacks : LinkedHashMap<NiaBackStackKey, SnapshotStateList<NiaBackStackKey>> = linkedMapOf(
startKey to mutableStateListOf(startKey)
)
@VisibleForTesting
val backStack: SnapshotStateList<NiaNavKey> = mutableStateListOf(startKey)
// Expose the current top level route for consumers
var currentTopLevelKey by mutableStateOf(startKey)
var currentTopLevelKey: NiaNavKey by mutableStateOf(backStackMap.keys.last())
private set
internal val currentKey: NiaBackStackKey
get() = topLevelStacks[currentTopLevelKey]!!.last()
@get:VisibleForTesting
val currentKey: NiaNavKey
get() = backStackMap[currentTopLevelKey]!!.last()
private fun updateBackStack() =
backStack.apply {
clear()
addAll(topLevelStacks.flatMap { it.value })
fun navigate(key: NiaNavKey) {
when {
// top level singleTop -> clear substack
key == currentTopLevelKey -> backStackMap[key] = mutableListOf(key)
// top level non-singleTop
key.isTopLevel -> {
// if navigating back to start destination, pop all other top destinations and
// store start destination substack
if (key == startKey) {
val tempStack = mapOf(startKey to backStackMap[startKey]!!)
backStackMap.clear()
backStackMap.putAll(tempStack)
// else either restore an existing substack or initiate new one
} else {
backStackMap[key] = backStackMap.remove(key) ?: mutableListOf(key)
}
}
// not top level - add to current substack
else -> {
val currentStack = backStackMap.values.last()
// single top
if (currentStack.lastOrNull() == key) {
currentStack.removeLastOrNull()
}
currentStack.add(key)
}
}
updateBackStack()
}
fun navigateToTopLevelDestination(key: NiaBackStackKey){
// If the top level doesn't exist, add it
if (topLevelStacks[key] == null){
topLevelStacks.put(key, mutableStateListOf(key))
} else {
// Otherwise just move it to the end of the stacks
topLevelStacks.apply {
remove(key)?.let {
put(key, it)
fun popLast(count: Int = 1) {
var popCount = count
var currentEntry = backStackMap.entries.last()
while (popCount > 0) {
val currentStack = currentEntry.value
if (currentStack.size == 1) {
// if current sub-stack only has one key, remove the sub-stack from the map
backStackMap.remove(currentEntry.key)
when {
// throw if map is empty after pop
backStackMap.isEmpty() -> error(popErrorMessage(count, currentEntry.key))
// otherwise update currentEntry
else -> currentEntry = backStackMap.entries.last()
}
} else {
// if current sub-stack has more than one key, just pop the last key off the sub-stack
currentStack.removeLastOrNull()
}
popCount--
}
currentTopLevelKey = key
updateBackStack()
}
fun navigate(key: NiaBackStackKey){
if (backStack.lastOrNull() != key) {
topLevelStacks[currentTopLevelKey]?.add(key)
updateBackStack()
private fun updateBackStack() {
backStack.apply {
clear()
backStack.addAll(
backStackMap.flatMap { it.value },
)
}
currentTopLevelKey = backStackMap.keys.last()
}
fun removeLast(){
val removedKey = topLevelStacks[currentTopLevelKey]?.removeLastOrNull()
// If the removed key was a top level key, remove the associated top level stack
topLevelStacks.remove(removedKey)
currentTopLevelKey = topLevelStacks.keys.last()
internal fun restore(map: LinkedHashMap<NiaNavKey, MutableList<NiaNavKey>>?) {
map ?: return
backStackMap.clear()
backStackMap.putAll(map)
updateBackStack()
}
}
interface NiaBackStackKey
interface NiaNavKey {
val isTopLevel: Boolean
}
private fun popErrorMessage(count: Int, lastPopped: NiaNavKey) =
"""
Failed to pop $count entries. BackStack has been popped to an empty stack. Last
popped key is $lastPopped.
""".trimIndent()

@ -0,0 +1,70 @@
/*
* 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.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.MapSerializer
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.serializer
import javax.inject.Inject
@HiltViewModel
class NiaBackStackViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
val niaBackStack: NiaBackStack,
serializersModules: SerializersModule,
) : ViewModel() {
private val config = SavedStateConfiguration { serializersModule = serializersModules }
@VisibleForTesting
internal var backStackMap by savedStateHandle.saved(
serializer = getMapSerializer<NiaNavKey>(),
configuration = config,
) {
linkedMapOf()
}
init {
if (backStackMap.isNotEmpty()) {
// Restore backstack from saved state handle if not emtpy
@Suppress("UNCHECKED_CAST")
niaBackStack.restore(
backStackMap as LinkedHashMap<NiaNavKey, MutableList<NiaNavKey>>,
)
}
// Start observing changes to the backStack and save backStack whenever it updates
viewModelScope.launch {
snapshotFlow {
niaBackStack.backStack.toList()
backStackMap = niaBackStack.backStackMap
}.collect()
}
}
}
private inline fun <reified T : NiaNavKey> getMapSerializer() = MapSerializer(serializer<T>(), serializer<List<T>>())

@ -0,0 +1,280 @@
/*
* 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 com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
class NiaBackStackTest {
private lateinit var niaBackStack: NiaBackStack
@Before
fun setup() {
niaBackStack = NiaBackStack(TestStartKey)
}
@Test
fun testStartKey() {
assertThat(niaBackStack.currentKey).isEqualTo(TestStartKey)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testNavigate() {
niaBackStack.navigate(TestKeyFirst)
assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testNavigateTopLevel() {
niaBackStack.navigate(TestTopLevelKey)
assertThat(niaBackStack.currentKey).isEqualTo(TestTopLevelKey)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey)
}
@Test
fun testNavigateSingleTop() {
niaBackStack.navigate(TestKeyFirst)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
niaBackStack.navigate(TestKeyFirst)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
}
@Test
fun testNavigateTopLevelSingleTop() {
niaBackStack.navigate(TestTopLevelKey)
niaBackStack.navigate(TestKeyFirst)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestTopLevelKey,
TestKeyFirst,
).inOrder()
niaBackStack.navigate(TestTopLevelKey)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestTopLevelKey,
).inOrder()
}
@Test
fun testSubStack() {
niaBackStack.navigate(TestKeyFirst)
assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
niaBackStack.navigate(TestKeySecond)
assertThat(niaBackStack.currentKey).isEqualTo(TestKeySecond)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testMultiStack() {
// add to start stack
niaBackStack.navigate(TestKeyFirst)
assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
// navigate to new top level
niaBackStack.navigate(TestTopLevelKey)
assertThat(niaBackStack.currentKey).isEqualTo(TestTopLevelKey)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey)
// add to new stack
niaBackStack.navigate(TestKeySecond)
assertThat(niaBackStack.currentKey).isEqualTo(TestKeySecond)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey)
// go back to start stack
niaBackStack.navigate(TestStartKey)
assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testRestore() {
assertThat(niaBackStack.backStack).containsExactly(TestStartKey)
niaBackStack.restore(
linkedMapOf(
TestStartKey to mutableListOf(TestStartKey, TestKeyFirst),
TestTopLevelKey to mutableListOf(TestTopLevelKey, TestKeySecond),
),
)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKey,
TestKeySecond,
).inOrder()
assertThat(niaBackStack.currentKey).isEqualTo(TestKeySecond)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey)
}
@Test
fun testPopOneNonTopLevel() {
niaBackStack.navigate(TestKeyFirst)
niaBackStack.navigate(TestKeySecond)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
niaBackStack.popLast()
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun testPopOneTopLevel() {
niaBackStack.navigate(TestKeyFirst)
niaBackStack.navigate(TestTopLevelKey)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestTopLevelKey,
).inOrder()
assertThat(niaBackStack.currentKey).isEqualTo(TestTopLevelKey)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey)
// remove TopLevel
niaBackStack.popLast()
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
).inOrder()
assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun popMultipleNonTopLevel() {
niaBackStack.navigate(TestKeyFirst)
niaBackStack.navigate(TestKeySecond)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
niaBackStack.popLast(2)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
).inOrder()
assertThat(niaBackStack.currentKey).isEqualTo(TestStartKey)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun popMultipleTopLevel() {
val testTopLevelKeyTwo = object : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}
// second sub-stack
niaBackStack.navigate(TestTopLevelKey)
niaBackStack.navigate(TestKeyFirst)
// third sub-stack
niaBackStack.navigate(testTopLevelKeyTwo)
niaBackStack.navigate(TestKeySecond)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
TestTopLevelKey,
TestKeyFirst,
testTopLevelKeyTwo,
TestKeySecond,
).inOrder()
niaBackStack.popLast(4)
assertThat(niaBackStack.backStack).containsExactly(
TestStartKey,
).inOrder()
assertThat(niaBackStack.currentKey).isEqualTo(TestStartKey)
assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey)
}
@Test
fun throwOnEmptyBackStack() {
assertFailsWith<IllegalStateException> {
niaBackStack.popLast(1)
}
}
}
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
}

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

@ -15,13 +15,9 @@
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.feature.api)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api"
}
dependencies {
api(projects.core.navigation)
}
}

@ -16,25 +16,11 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import kotlinx.serialization.Serializable
@Serializable object BookmarksRoute: NiaBackStackKey
fun NavController.navigateToBookmarks(navOptions: NavOptions) =
navigate(route = BookmarksRoute, navOptions)
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
// composable<BookmarksRoute> {
// BookmarksRoute(
// onTopicClick,
// onShowSnackbar
// )
// }
@Serializable
object BookmarksRoute : NiaNavKey {
override val isTopLevel: Boolean
get() = true
}

@ -15,10 +15,10 @@
limitations under the License.
-->
<resources>
<string name="feature_bookmarks_impl_title">Saved</string>
<string name="feature_bookmarks_impl_loading">Loading saved…</string>
<string name="feature_bookmarks_impl_empty_error">No saved updates</string>
<string name="feature_bookmarks_impl_empty_description">Updates you save will be stored here\nto read later</string>
<string name="feature_bookmarks_impl_removed">Bookmark removed</string>
<string name="feature_bookmarks_impl_undo">UNDO</string>
<string name="feature_bookmarks_api_title">Saved</string>
<string name="feature_bookmarks_api_loading">Loading saved…</string>
<string name="feature_bookmarks_api_empty_error">No saved updates</string>
<string name="feature_bookmarks_api_empty_description">Updates you save will be stored here\nto read later</string>
<string name="feature_bookmarks_api_removed">Bookmark removed</string>
<string name="feature_bookmarks_api_undo">UNDO</string>
</resources>

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

@ -15,7 +15,7 @@
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.feature.impl)
alias(libs.plugins.nowinandroid.android.library.compose)
}

@ -36,6 +36,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.testing.TestLifecycleOwner
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -64,7 +65,7 @@ class BookmarksScreenTest {
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_bookmarks_impl_loading),
composeTestRule.activity.resources.getString(R.string.feature_bookmarks_api_loading),
)
.assertExists()
}
@ -161,13 +162,13 @@ class BookmarksScreenTest {
composeTestRule
.onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_impl_empty_error),
composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error),
)
.assertExists()
composeTestRule
.onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_impl_empty_description),
composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description),
)
.assertExists()
}

@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -74,6 +74,7 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R
@Composable
internal fun BookmarksScreen(
@ -112,8 +113,8 @@ internal fun BookmarksScreen(
undoBookmarkRemoval: () -> Unit = {},
clearUndoState: () -> Unit = {},
) {
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_impl_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_impl_undo)
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_api_undo)
LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) {
@ -155,7 +156,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
.fillMaxWidth()
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.feature_bookmarks_impl_loading),
contentDesc = stringResource(id = R.string.feature_bookmarks_api_loading),
)
}
@ -228,7 +229,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
val iconTint = LocalTintTheme.current.iconTint
Image(
modifier = Modifier.fillMaxWidth(),
painter = painterResource(id = R.drawable.feature_bookmarks_impl_mg_empty_bookmarks),
painter = painterResource(id = R.drawable.feature_bookmarks_api_mg_empty_bookmarks),
colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null,
contentDescription = null,
)
@ -236,7 +237,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(48.dp))
Text(
text = stringResource(id = R.string.feature_bookmarks_impl_empty_error),
text = stringResource(id = R.string.feature_bookmarks_api_empty_error),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
@ -246,7 +247,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.feature_bookmarks_impl_empty_description),
text = stringResource(id = R.string.feature_bookmarks_api_empty_description),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,

@ -23,7 +23,7 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
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.navigateToTopic
@ -35,13 +35,13 @@ import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object BookmarksModule {
object BookmarksEntryProvider {
@Provides
@IntoSet
fun provideBookmarksEntryProviderBuilder(
backStack: NiaBackStack,
): EntryProviderBuilder<NiaBackStackKey>.() -> Unit = {
): EntryProviderBuilder<NiaNavKey>.() -> Unit = {
entry<BookmarksRoute> {
val snackbarHostState = LocalSnackbarHostState.current
BookmarksScreen(
@ -52,7 +52,7 @@ object BookmarksModule {
actionLabel = action,
duration = Short,
) == ActionPerformed
}
},
)
}
}

@ -0,0 +1,40 @@
/*
* 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())
}
}

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

@ -15,7 +15,7 @@
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.feature.api)
}
android {

@ -1,59 +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.feature.foryou.api.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import kotlinx.serialization.Serializable
@Serializable data object ForYouRoute: NiaBackStackKey // route to ForYou screen
fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute, navOptions)
/**
* The ForYou section of the app. It can also display information about topics.
* This should be supplied from a separate module.
*
* @param onTopicClick - Called when a topic is clicked, contains the ID of the topic
* @param topicDestination - Destination for topic content
*/
fun NavGraphBuilder.forYouSection(
onTopicClick: (String) -> Unit,
topicDestination: NavGraphBuilder.() -> Unit,
) {
// navigation<ForYouBaseRoute>(startDestination = ForYouRoute) {
// composable<ForYouRoute>(
// deepLinks = listOf(
// navDeepLink {
// /**
// * This destination has a deep link that enables a specific news resource to be
// * opened from a notification (@see SystemTrayNotifier for more). The news resource
// * ID is sent in the URI rather than being modelled in the route type because it's
// * transient data (stored in SavedStateHandle) that is cleared after the user has
// * opened the news resource.
// */
// uriPattern = DEEP_LINK_URI_PATTERN
// },
// ),
// ) {
// com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen(onTopicClick)
// }
// topicDestination()
// }
}

@ -0,0 +1,26 @@
/*
* 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.feature.foryou.api.navigation
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import kotlinx.serialization.Serializable
@Serializable
object ForYouRoute : NiaNavKey { // route to ForYou screen
override val isTopLevel: Boolean
get() = true
}

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

@ -15,7 +15,7 @@
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.feature.impl)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.roborazzi)
}
@ -26,11 +26,11 @@ android {
dependencies {
implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain)
implementation(projects.core.notifications)
implementation(projects.feature.foryou.api)
implementation(projects.feature.topic.api)
implementation(libs.androidx.activity.compose)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)

@ -154,12 +154,12 @@ class ForYouScreenTest {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(
// Follow one topic
topics = followableTopicTestData.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
},
),
OnboardingUiState.Shown(
// Follow one topic
topics = followableTopicTestData.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
},
),
feedState = NewsFeedUiState.Success(
feed = emptyList(),
),
@ -201,9 +201,9 @@ class ForYouScreenTest {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(
topics = followableTopicTestData
),
OnboardingUiState.Shown(
topics = followableTopicTestData,
),
feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou.impl
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
@ -79,7 +80,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus.Denied
@ -105,7 +106,8 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
@Composable
internal fun ForYouScreen(
@VisibleForTesting
public fun ForYouScreen(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel? = if (LocalInspectionMode.current) null else hiltViewModel(),

@ -19,7 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
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.navigateToTopic
@ -31,17 +31,20 @@ import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object ForYouModule {
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(
backStack: NiaBackStack,
): EntryProviderBuilder<NiaBackStackKey>.() -> Unit = {
): EntryProviderBuilder<NiaNavKey>.() -> Unit = {
entry<ForYouRoute> {
ForYouScreen(
onTopicClick = backStack::navigateToTopic
onTopicClick = backStack::navigateToTopic,
)
}
}
}
}

@ -0,0 +1,40 @@
/*
* 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())
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

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

@ -15,13 +15,9 @@
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.feature.api)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.interests.api"
}
dependencies {
api(projects.core.navigation)
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save