Update feature ViewModels and tests

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

@ -86,7 +86,6 @@ dependencies {
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.core.model) implementation(projects.core.model)
implementation(projects.core.analytics) implementation(projects.core.analytics)
implementation(projects.core.navigation)
implementation(projects.sync.work) implementation(projects.sync.work)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -100,10 +99,8 @@ dependencies {
implementation(libs.androidx.compose.runtime.tracing) implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.lifecycle.viewModel.navigation3) implementation(libs.androidx.lifecycle.viewModel.navigation3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.window.core) 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.semantics.SemanticsActions.ScrollBy
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasTestTag 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.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Ignore
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@ -85,10 +88,10 @@ class NavigationTest {
// The strings used for matching in these tests // The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_navigate_up) 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 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 sampleTopic = "Headlines"
private val appName by composeTestRule.stringResource(R.string.app_name) 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 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 brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text) 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 @Test
fun navigationBar_multipleBackStackInterests() { fun navigationBar_multipleBackStackInterests() {
composeTestRule.apply { composeTestRule.apply {
@ -261,12 +267,14 @@ class NavigationTest {
val topic = runBlocking { val topic = runBlocking {
topicsRepository.getTopics().first().sortedBy(Topic::name).last() 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() onNodeWithText(topic.name).performClick()
// Verify the topic is still shown
onNodeWithTag("topic:${topic.id}").assertIsDisplayed()
// Switch tab // Switch tab
onNodeWithText(forYou).performClick() onNodeWithText(forYou).performClick()
// Come back to Interests // Come back to Interests
onNodeWithText(interests).performClick() 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.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor 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.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.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.NiaApp
import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState
import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme
@ -77,11 +77,10 @@ class MainActivity : ComponentActivity() {
lateinit var userNewsResourceRepository: UserNewsResourceRepository lateinit var userNewsResourceRepository: UserNewsResourceRepository
private val viewModel: MainActivityViewModel by viewModels() private val viewModel: MainActivityViewModel by viewModels()
@Inject private val backStackViewModel: NiaBackStackViewModel by viewModels()
lateinit var niaBackStack: NiaBackStack
@Inject @Inject
lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit> lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaNavKey>.() -> Unit>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
@ -145,7 +144,7 @@ class MainActivity : ComponentActivity() {
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
niaBackStack = niaBackStack, niaBackStack = backStackViewModel.niaBackStack,
) )
val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle() val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()
@ -161,7 +160,7 @@ class MainActivity : ComponentActivity() {
) { ) {
NiaApp( NiaApp(
appState, appState,
entryProviderBuilders entryProviderBuilders,
) )
} }
} }

@ -17,12 +17,16 @@
package com.google.samples.apps.nowinandroid.di package com.google.samples.apps.nowinandroid.di
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack 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 com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import javax.inject.Singleton
import dagger.hilt.components.SingletonComponent 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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -31,4 +35,19 @@ object BackStackProvider {
@Singleton @Singleton
fun provideNiaBackStack(): NiaBackStack = fun provideNiaBackStack(): NiaBackStack =
NiaBackStack(startKey = TopLevelDestination.FOR_YOU.key) 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.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderBuilder import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
@ -27,20 +26,20 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack 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) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun NiaNavDisplay( fun NiaNavDisplay(
niaBackStack: NiaBackStack, niaBackStack: NiaBackStack,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit>, entryProviderBuilders: Set<EntryProviderBuilder<NiaNavKey>.() -> Unit>,
) { ) {
val listDetailStrategy = rememberListDetailSceneStrategy<NiaBackStackKey>() val listDetailStrategy = rememberListDetailSceneStrategy<NiaNavKey>()
NavDisplay( NavDisplay(
backStack = niaBackStack.backStack, backStack = niaBackStack.backStack,
sceneStrategy = listDetailStrategy, sceneStrategy = listDetailStrategy,
onBack = { niaBackStack.removeLast() }, onBack = { count -> niaBackStack.popLast(count) },
entryDecorators = listOf( entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(), rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(), 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 androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.bookmarks.api.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute 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.interests.api.navigation.InterestsRoute
import kotlin.reflect.KClass 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.foryou.api.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR 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 iconTextId: Int,
@StringRes val titleTextId: Int, @StringRes val titleTextId: Int,
val route: KClass<*>, val route: KClass<*>,
val key: NiaBackStackKey val key: NiaNavKey,
) { ) {
FOR_YOU( FOR_YOU(
selectedIcon = NiaIcons.Upcoming, selectedIcon = NiaIcons.Upcoming,
@ -57,15 +57,15 @@ enum class TopLevelDestination(
iconTextId = forYouR.string.feature_foryou_api_title, iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name, titleTextId = R.string.app_name,
route = ForYouRoute::class, route = ForYouRoute::class,
key = ForYouRoute key = ForYouRoute,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks, selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder, unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_impl_title, iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_impl_title, titleTextId = bookmarksR.string.feature_bookmarks_api_title,
route = BookmarksRoute::class, route = BookmarksRoute::class,
key = BookmarksRoute key = BookmarksRoute,
), ),
INTERESTS( INTERESTS(
selectedIcon = NiaIcons.Grid3x3, selectedIcon = NiaIcons.Grid3x3,
@ -73,7 +73,7 @@ enum class TopLevelDestination(
iconTextId = searchR.string.feature_search_api_interests, iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests, titleTextId = searchR.string.feature_search_api_interests,
route = InterestsRoute::class, 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.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 androidx.navigation3.runtime.EntryProviderBuilder
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery 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.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog 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.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.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsDialog 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 @Composable
fun NiaApp( fun NiaApp(
appState: NiaAppState, appState: NiaAppState,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit>, entryProviderBuilders: Set<EntryProviderBuilder<NiaNavKey>.() -> Unit>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) { ) {
@ -146,7 +144,7 @@ fun NiaApp(
) )
internal fun NiaApp( internal fun NiaApp(
appState: NiaAppState, appState: NiaAppState,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<NiaBackStackKey>.() -> Unit>, entryProviderBuilders: Set<EntryProviderBuilder<NiaNavKey>.() -> Unit>,
showSettingsDialog: Boolean, showSettingsDialog: Boolean,
onSettingsDismissed: () -> Unit, onSettingsDismissed: () -> Unit,
onTopAppBarActionClick: () -> Unit, onTopAppBarActionClick: () -> Unit,
@ -157,7 +155,6 @@ internal fun NiaApp(
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
val currentTopLevelKey = appState.currentTopLevelDestination!!.key val currentTopLevelKey = appState.currentTopLevelDestination!!.key
if (showSettingsDialog) { if (showSettingsDialog) {
SettingsDialog( SettingsDialog(
onDismiss = { onSettingsDismissed() }, onDismiss = { onSettingsDismissed() },
@ -173,7 +170,7 @@ internal fun NiaApp(
val selected = destination.key == currentTopLevelKey val selected = destination.key == currentTopLevelKey
item( item(
selected = selected, selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) }, onClick = { appState.niaBackStack.navigate(destination.key) },
icon = { icon = {
Icon( Icon(
imageVector = destination.unselectedIcon, imageVector = destination.unselectedIcon,
@ -258,10 +255,6 @@ internal fun NiaApp(
}, },
), ),
) { ) {
// NiaNavHost(
// appState = appState,
// onShowSnackbar = onShowSnackbar,
// )
NiaNavDisplay( NiaNavDisplay(
niaBackStack = appState.niaBackStack, niaBackStack = appState.niaBackStack,
entryProviderBuilders, entryProviderBuilders,

@ -18,23 +18,17 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController import androidx.compose.runtime.snapshotFlow
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository 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.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor 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.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
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS 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.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestinations import com.google.samples.apps.nowinandroid.navigation.TopLevelDestinations
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -51,12 +45,10 @@ fun rememberNiaAppState(
timeZoneMonitor: TimeZoneMonitor, timeZoneMonitor: TimeZoneMonitor,
niaBackStack: NiaBackStack, niaBackStack: NiaBackStack,
coroutineScope: CoroutineScope = rememberCoroutineScope(), coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState { ): NiaAppState {
NavigationTrackingSideEffect(navController) NavigationTrackingSideEffect(niaBackStack)
return remember( return remember(
niaBackStack, niaBackStack,
navController,
coroutineScope, coroutineScope,
networkMonitor, networkMonitor,
userNewsResourceRepository, userNewsResourceRepository,
@ -64,7 +56,6 @@ fun rememberNiaAppState(
) { ) {
NiaAppState( NiaAppState(
niaBackStack = niaBackStack, niaBackStack = niaBackStack,
navController = navController,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
@ -76,28 +67,11 @@ fun rememberNiaAppState(
@Stable @Stable
class NiaAppState( class NiaAppState(
val niaBackStack: NiaBackStack, val niaBackStack: NiaBackStack,
val navController: NavHostController,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor, 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? val currentTopLevelDestination: TopLevelDestination?
@Composable get() = TopLevelDestinations[niaBackStack.currentTopLevelKey] @Composable get() = TopLevelDestinations[niaBackStack.currentTopLevelKey]
@ -138,58 +112,18 @@ class NiaAppState(
SharingStarted.WhileSubscribed(5_000), SharingStarted.WhileSubscribed(5_000),
TimeZone.currentSystemDefault(), 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 * Stores information about navigation events to be used with JankStats
*/ */
@Composable @Composable
private fun NavigationTrackingSideEffect(navController: NavHostController) { private fun NavigationTrackingSideEffect(niaBackStack: NiaBackStack) {
TrackDisposableJank(navController) { metricsHolder -> TrackDisposableJank(niaBackStack) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ -> snapshotFlow {
metricsHolder.state?.putState("Navigation", destination.route.toString()) val stack = niaBackStack.backStack.toList()
} metricsHolder.state?.putState("Navigation", stack.lastOrNull().toString())
navController.addOnDestinationChangedListener(listener)
onDispose {
navController.removeOnDestinationChangedListener(listener)
} }
onDispose { }
} }
} }

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

@ -16,21 +16,15 @@
package com.google.samples.apps.nowinandroid.ui 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.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule 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.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository 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.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor 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.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.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -70,30 +64,29 @@ class NiaAppStateTest {
@Test @Test
fun niaAppState_currentDestination() = runTest { fun niaAppState_currentDestination() = runTest {
var currentDestination: String? = null val niaBackStack = mockNiaBackStack()
composeTestRule.setContent { composeTestRule.setContent {
val navController = rememberTestNavController() state = remember(niaBackStack) {
state = remember(navController) {
NiaAppState( NiaAppState(
navController = navController, niaBackStack = niaBackStack,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
) )
} }
}
// Update currentDestination whenever it changes assertEquals(ForYouRoute, state.niaBackStack.currentTopLevelKey)
currentDestination = state.niaBackStack.currentKey assertEquals(ForYouRoute, state.niaBackStack.currentKey)
// Navigate to destination b once // Navigate to another destination once
LaunchedEffect(Unit) { niaBackStack.navigate(BookmarksRoute)
navController.setCurrentDestination("b")
} composeTestRule.waitForIdle()
}
assertEquals("b", currentDestination) assertEquals(BookmarksRoute, state.niaBackStack.currentTopLevelKey)
assertEquals(BookmarksRoute, state.niaBackStack.currentKey)
} }
@Test @Test
@ -103,6 +96,7 @@ class NiaAppStateTest {
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
) )
} }
@ -116,11 +110,11 @@ class NiaAppStateTest {
fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) { fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
) )
} }
@ -136,11 +130,11 @@ class NiaAppStateTest {
fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) { fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
) )
} }
val changedTz = TimeZone.of("Europe/Prague") 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.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme 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.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -147,9 +148,7 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun phone_noSnackbar() { fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"insets_snackbar_compact_medium_noSnackbar", "insets_snackbar_compact_medium_noSnackbar",
@ -159,13 +158,11 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun snackbarShown_phone() { fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"insets_snackbar_compact_medium", "insets_snackbar_compact_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -176,13 +173,11 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun snackbarShown_foldable() { fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp, 600.dp,
600.dp, 600.dp,
"insets_snackbar_medium_medium", "insets_snackbar_medium_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -193,13 +188,11 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun snackbarShown_tablet() { fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp, 900.dp,
900.dp, 900.dp,
"insets_snackbar_expanded_expanded", "insets_snackbar_expanded_expanded",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -209,17 +202,18 @@ class SnackbarInsetsScreenshotTests {
} }
private fun testSnackbarScreenshotWithSize( private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp, width: Dp,
height: Dp, height: Dp,
screenshotName: String, screenshotName: String,
action: suspend () -> Unit, action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) { ) {
lateinit var scope: CoroutineScope lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider( CompositionLocalProvider(
// Replaces images with placeholders // Replaces images with placeholders
LocalInspectionMode provides true, LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) { ) {
scope = rememberCoroutineScope() scope = rememberCoroutineScope()
@ -256,10 +250,11 @@ class SnackbarInsetsScreenshotTests {
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
) )
NiaApp( NiaApp(
appState = appState, appState = appState,
snackbarHostState = snackbarHostState, entryProviderBuilders = MockEntryProvider,
showSettingsDialog = false, showSettingsDialog = false,
onSettingsDismissed = {}, onSettingsDismissed = {},
onTopAppBarActionClick = {}, onTopAppBarActionClick = {},
@ -280,7 +275,7 @@ class SnackbarInsetsScreenshotTests {
} }
scope.launch { scope.launch {
action() action(snackbarHostState)
} }
composeTestRule.onNodeWithTag("root") 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.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme 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.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -120,9 +121,7 @@ class SnackbarScreenshotTests {
@Test @Test
fun phone_noSnackbar() { fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"snackbar_compact_medium_noSnackbar", "snackbar_compact_medium_noSnackbar",
@ -132,13 +131,11 @@ class SnackbarScreenshotTests {
@Test @Test
fun snackbarShown_phone() { fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"snackbar_compact_medium", "snackbar_compact_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -149,13 +146,11 @@ class SnackbarScreenshotTests {
@Test @Test
fun snackbarShown_foldable() { fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp, 600.dp,
600.dp, 600.dp,
"snackbar_medium_medium", "snackbar_medium_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -166,13 +161,11 @@ class SnackbarScreenshotTests {
@Test @Test
fun snackbarShown_tablet() { fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp, 900.dp,
900.dp, 900.dp,
"snackbar_expanded_expanded", "snackbar_expanded_expanded",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -182,17 +175,19 @@ class SnackbarScreenshotTests {
} }
private fun testSnackbarScreenshotWithSize( private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp, width: Dp,
height: Dp, height: Dp,
screenshotName: String, screenshotName: String,
action: suspend () -> Unit, action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) { ) {
lateinit var scope: CoroutineScope lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider( CompositionLocalProvider(
// Replaces images with placeholders // Replaces images with placeholders
LocalInspectionMode provides true, LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) { ) {
scope = rememberCoroutineScope() scope = rememberCoroutineScope()
@ -205,10 +200,11 @@ class SnackbarScreenshotTests {
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
niaBackStack = mockNiaBackStack(),
) )
NiaApp( NiaApp(
appState = appState, appState = appState,
snackbarHostState = snackbarHostState, entryProviderBuilders = MockEntryProvider,
showSettingsDialog = false, showSettingsDialog = false,
onSettingsDismissed = {}, onSettingsDismissed = {},
onTopAppBarActionClick = {}, onTopAppBarActionClick = {},
@ -227,7 +223,7 @@ class SnackbarScreenshotTests {
} }
scope.launch { scope.launch {
action() action(snackbarHostState)
} }
composeTestRule.onRoot() 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` # `:benchmarks`
## Module dependency graph ## Module dependency graph
@ -132,3 +133,8 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
</details> </details>
<!--endregion--> <!--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 id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
implementationClass = "AndroidLibraryConventionPlugin" implementationClass = "AndroidLibraryConventionPlugin"
} }
register("androidFeature") { register("androidFeatureImpl") {
id = libs.plugins.nowinandroid.android.feature.get().pluginId id = libs.plugins.nowinandroid.android.feature.impl.get().pluginId
implementationClass = "AndroidFeatureConventionPlugin" implementationClass = "AndroidFeatureImplConventionPlugin"
}
register("androidFeatureApi") {
id = libs.plugins.nowinandroid.android.feature.api.get().pluginId
implementationClass = "AndroidFeatureApiConventionPlugin"
} }
register("androidLibraryJacoco") { register("androidLibraryJacoco") {
id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId

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

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

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.provider.Provider import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension 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 { plugins {
alias(libs.plugins.nowinandroid.jvm.library) alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.hilt) 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 { dependencies {
api(libs.androidx.navigation3.runtime) 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.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import javax.inject.Inject import org.jetbrains.annotations.VisibleForTesting
import kotlin.collections.remove import kotlin.collections.mutableListOf
class NiaBackStack @Inject constructor( // TODO refine back behavior - perhaps take a lambda so that each screen / use site can customize back behavior?
startKey: NiaBackStackKey, // 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 @VisibleForTesting
private var topLevelStacks : LinkedHashMap<NiaBackStackKey, SnapshotStateList<NiaBackStackKey>> = linkedMapOf( val backStack: SnapshotStateList<NiaNavKey> = mutableStateListOf(startKey)
startKey to mutableStateListOf(startKey)
)
// Expose the current top level route for consumers var currentTopLevelKey: NiaNavKey by mutableStateOf(backStackMap.keys.last())
var currentTopLevelKey by mutableStateOf(startKey)
private set private set
internal val currentKey: NiaBackStackKey @get:VisibleForTesting
get() = topLevelStacks[currentTopLevelKey]!!.last() val currentKey: NiaNavKey
get() = backStackMap[currentTopLevelKey]!!.last()
private fun updateBackStack() = fun navigate(key: NiaNavKey) {
backStack.apply { when {
clear() // top level singleTop -> clear substack
addAll(topLevelStacks.flatMap { it.value }) 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){ fun popLast(count: Int = 1) {
// If the top level doesn't exist, add it var popCount = count
if (topLevelStacks[key] == null){ var currentEntry = backStackMap.entries.last()
topLevelStacks.put(key, mutableStateListOf(key)) while (popCount > 0) {
} else { val currentStack = currentEntry.value
// Otherwise just move it to the end of the stacks if (currentStack.size == 1) {
topLevelStacks.apply { // if current sub-stack only has one key, remove the sub-stack from the map
remove(key)?.let { backStackMap.remove(currentEntry.key)
put(key, it) 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() updateBackStack()
} }
fun navigate(key: NiaBackStackKey){ private fun updateBackStack() {
if (backStack.lastOrNull() != key) { backStack.apply {
topLevelStacks[currentTopLevelKey]?.add(key) clear()
updateBackStack() backStack.addAll(
backStackMap.flatMap { it.value },
)
} }
currentTopLevelKey = backStackMap.keys.last()
} }
fun removeLast(){ internal fun restore(map: LinkedHashMap<NiaNavKey, MutableList<NiaNavKey>>?) {
val removedKey = topLevelStacks[currentTopLevelKey]?.removeLastOrNull() map ?: return
// If the removed key was a top level key, remove the associated top level stack backStackMap.clear()
topLevelStacks.remove(removedKey) backStackMap.putAll(map)
currentTopLevelKey = topLevelStacks.keys.last()
updateBackStack() 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 { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature.api)
} }
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api" 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 package com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation
import androidx.navigation.NavController import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable object BookmarksRoute: NiaBackStackKey @Serializable
object BookmarksRoute : NiaNavKey {
fun NavController.navigateToBookmarks(navOptions: NavOptions) = override val isTopLevel: Boolean
navigate(route = BookmarksRoute, navOptions) get() = true
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
// composable<BookmarksRoute> {
// BookmarksRoute(
// onTopicClick,
// onShowSnackbar
// )
// }
} }

@ -15,10 +15,10 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<string name="feature_bookmarks_impl_title">Saved</string> <string name="feature_bookmarks_api_title">Saved</string>
<string name="feature_bookmarks_impl_loading">Loading saved…</string> <string name="feature_bookmarks_api_loading">Loading saved…</string>
<string name="feature_bookmarks_impl_empty_error">No saved updates</string> <string name="feature_bookmarks_api_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_api_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_api_removed">Bookmark removed</string>
<string name="feature_bookmarks_impl_undo">UNDO</string> <string name="feature_bookmarks_api_undo">UNDO</string>
</resources> </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 { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature.impl)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
} }

@ -36,6 +36,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.testing.TestLifecycleOwner import androidx.lifecycle.testing.TestLifecycleOwner
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData 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.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -64,7 +65,7 @@ class BookmarksScreenTest {
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_bookmarks_impl_loading), composeTestRule.activity.resources.getString(R.string.feature_bookmarks_api_loading),
) )
.assertExists() .assertExists()
} }
@ -161,13 +162,13 @@ class BookmarksScreenTest {
composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_impl_empty_error), composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error),
) )
.assertExists() .assertExists()
composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_impl_empty_description), composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description),
) )
.assertExists() .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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R
@Composable @Composable
internal fun BookmarksScreen( internal fun BookmarksScreen(
@ -112,8 +113,8 @@ internal fun BookmarksScreen(
undoBookmarkRemoval: () -> Unit = {}, undoBookmarkRemoval: () -> Unit = {},
clearUndoState: () -> Unit = {}, clearUndoState: () -> Unit = {},
) { ) {
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_impl_removed) val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_impl_undo) val undoText = stringResource(id = R.string.feature_bookmarks_api_undo)
LaunchedEffect(shouldDisplayUndoBookmark) { LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) {
@ -155,7 +156,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
.fillMaxWidth() .fillMaxWidth()
.wrapContentSize() .wrapContentSize()
.testTag("forYou:loading"), .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 val iconTint = LocalTintTheme.current.iconTint
Image( Image(
modifier = Modifier.fillMaxWidth(), 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, colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null,
contentDescription = null, contentDescription = null,
) )
@ -236,7 +237,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
Text( Text(
text = stringResource(id = R.string.feature_bookmarks_impl_empty_error), text = stringResource(id = R.string.feature_bookmarks_api_empty_error),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -246,7 +247,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(id = R.string.feature_bookmarks_impl_empty_description), text = stringResource(id = R.string.feature_bookmarks_api_empty_description),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

@ -23,7 +23,7 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.navigation3.runtime.EntryProviderBuilder import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack 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.api.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
@ -35,13 +35,13 @@ import dagger.multibindings.IntoSet
@Module @Module
@InstallIn(ActivityComponent::class) @InstallIn(ActivityComponent::class)
object BookmarksModule { object BookmarksEntryProvider {
@Provides @Provides
@IntoSet @IntoSet
fun provideBookmarksEntryProviderBuilder( fun provideBookmarksEntryProviderBuilder(
backStack: NiaBackStack, backStack: NiaBackStack,
): EntryProviderBuilder<NiaBackStackKey>.() -> Unit = { ): EntryProviderBuilder<NiaNavKey>.() -> Unit = {
entry<BookmarksRoute> { entry<BookmarksRoute> {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
BookmarksScreen( BookmarksScreen(
@ -52,7 +52,7 @@ object BookmarksModule {
actionLabel = action, actionLabel = action,
duration = Short, duration = Short,
) == ActionPerformed ) == 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 { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature.api)
} }
android { 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 { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature.impl)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.roborazzi) alias(libs.plugins.roborazzi)
} }
@ -26,11 +26,11 @@ android {
dependencies { dependencies {
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(projects.core.notifications) implementation(projects.core.notifications)
implementation(projects.feature.foryou.api) implementation(projects.feature.foryou.api)
implementation(projects.feature.topic.api) implementation(projects.feature.topic.api)
implementation(libs.androidx.activity.compose)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)

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

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou.impl
import android.os.Build.VERSION import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.activity.compose.ReportDrawnWhen import androidx.activity.compose.ReportDrawnWhen
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus.Denied 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 import com.google.samples.apps.nowinandroid.feature.foryou.api.R
@Composable @Composable
internal fun ForYouScreen( @VisibleForTesting
public fun ForYouScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel? = if (LocalInspectionMode.current) null else hiltViewModel(), 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.EntryProviderBuilder
import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack 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.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
@ -31,17 +31,20 @@ import dagger.multibindings.IntoSet
@Module @Module
@InstallIn(ActivityComponent::class) @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 @Provides
@IntoSet @IntoSet
fun provideForYouEntryProviderBuilder( fun provideForYouEntryProviderBuilder(
backStack: NiaBackStack, backStack: NiaBackStack,
): EntryProviderBuilder<NiaBackStackKey>.() -> Unit = { ): EntryProviderBuilder<NiaNavKey>.() -> Unit = {
entry<ForYouRoute> { entry<ForYouRoute> {
ForYouScreen( 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) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class) @Config(application = HiltTestApplication::class, sdk = [35])
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class ForYouScreenScreenshotTests { 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 { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature.api)
} }
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.interests.api" 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