Merge pull request #1413 from android/dt/nav-safe-args

Add type-safe navigation
pull/1576/merge
Don Turner 2 months ago committed by GitHub
commit db65a6f32b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -25,6 +25,7 @@ plugins {
id("com.google.android.gms.oss-licenses-plugin") id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile) alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi) alias(libs.plugins.roborazzi)
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@ -103,6 +104,7 @@ dependencies {
implementation(libs.androidx.window.core) implementation(libs.androidx.window.core)
implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)

@ -2,25 +2,25 @@ androidx.activity:activity-compose:1.8.2
androidx.activity:activity-ktx:1.8.2 androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2 androidx.activity:activity:1.8.2
androidx.annotation:annotation-experimental:1.4.1 androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.8.0 androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.8.0 androidx.annotation:annotation:1.8.1
androidx.appcompat:appcompat-resources:1.7.0 androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.7.0 androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0 androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0 androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0 androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-jvm:1.4.2
androidx.collection:collection-ktx:1.4.0 androidx.collection:collection-ktx:1.4.2
androidx.collection:collection:1.4.0 androidx.collection:collection:1.4.2
androidx.compose.animation:animation-android:1.7.0-rc01 androidx.compose.animation:animation-android:1.7.0
androidx.compose.animation:animation-core-android:1.7.0-rc01 androidx.compose.animation:animation-core-android:1.7.0
androidx.compose.animation:animation-core:1.7.0-rc01 androidx.compose.animation:animation-core:1.7.0
androidx.compose.animation:animation:1.7.0-rc01 androidx.compose.animation:animation:1.7.0
androidx.compose.foundation:foundation-android:1.7.0-rc01 androidx.compose.foundation:foundation-android:1.7.0
androidx.compose.foundation:foundation-layout-android:1.7.0-rc01 androidx.compose.foundation:foundation-layout-android:1.7.0
androidx.compose.foundation:foundation-layout:1.7.0-rc01 androidx.compose.foundation:foundation-layout:1.7.0
androidx.compose.foundation:foundation:1.7.0-rc01 androidx.compose.foundation:foundation:1.7.0
androidx.compose.material3.adaptive:adaptive-android:1.0.0-rc01 androidx.compose.material3.adaptive:adaptive-android:1.0.0-rc01
androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-rc01 androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-rc01
androidx.compose.material3.adaptive:adaptive-layout:1.0.0-rc01 androidx.compose.material3.adaptive:adaptive-layout:1.0.0-rc01
@ -39,25 +39,25 @@ androidx.compose.material:material-icons-extended-android:1.6.3
androidx.compose.material:material-icons-extended:1.6.3 androidx.compose.material:material-icons-extended:1.6.3
androidx.compose.material:material-ripple-android:1.7.0-rc01 androidx.compose.material:material-ripple-android:1.7.0-rc01
androidx.compose.material:material-ripple:1.7.0-rc01 androidx.compose.material:material-ripple:1.7.0-rc01
androidx.compose.runtime:runtime-android:1.7.0-rc01 androidx.compose.runtime:runtime-android:1.7.0
androidx.compose.runtime:runtime-saveable-android:1.7.0-rc01 androidx.compose.runtime:runtime-saveable-android:1.7.0
androidx.compose.runtime:runtime-saveable:1.7.0-rc01 androidx.compose.runtime:runtime-saveable:1.7.0
androidx.compose.runtime:runtime-tracing:1.0.0-beta01 androidx.compose.runtime:runtime-tracing:1.0.0-beta01
androidx.compose.runtime:runtime:1.7.0-rc01 androidx.compose.runtime:runtime:1.7.0
androidx.compose.ui:ui-android:1.7.0-rc01 androidx.compose.ui:ui-android:1.7.0
androidx.compose.ui:ui-geometry-android:1.7.0-rc01 androidx.compose.ui:ui-geometry-android:1.7.0
androidx.compose.ui:ui-geometry:1.7.0-rc01 androidx.compose.ui:ui-geometry:1.7.0
androidx.compose.ui:ui-graphics-android:1.7.0-rc01 androidx.compose.ui:ui-graphics-android:1.7.0
androidx.compose.ui:ui-graphics:1.7.0-rc01 androidx.compose.ui:ui-graphics:1.7.0
androidx.compose.ui:ui-text-android:1.7.0-rc01 androidx.compose.ui:ui-text-android:1.7.0
androidx.compose.ui:ui-text:1.7.0-rc01 androidx.compose.ui:ui-text:1.7.0
androidx.compose.ui:ui-tooling-preview-android:1.7.0-rc01 androidx.compose.ui:ui-tooling-preview-android:1.7.0
androidx.compose.ui:ui-tooling-preview:1.7.0-rc01 androidx.compose.ui:ui-tooling-preview:1.7.0
androidx.compose.ui:ui-unit-android:1.7.0-rc01 androidx.compose.ui:ui-unit-android:1.7.0
androidx.compose.ui:ui-unit:1.7.0-rc01 androidx.compose.ui:ui-unit:1.7.0
androidx.compose.ui:ui-util-android:1.7.0-rc01 androidx.compose.ui:ui-util-android:1.7.0
androidx.compose.ui:ui-util:1.7.0-rc01 androidx.compose.ui:ui-util:1.7.0
androidx.compose.ui:ui:1.7.0-rc01 androidx.compose.ui:ui:1.7.0
androidx.compose:compose-bom:2024.02.02 androidx.compose:compose-bom:2024.02.02
androidx.concurrent:concurrent-futures:1.1.0 androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1 androidx.core:core-ktx:1.13.1
@ -106,11 +106,11 @@ androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.loader:loader:1.0.0 androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04 androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.navigation:navigation-common-ktx:2.8.0-beta06 androidx.navigation:navigation-common-ktx:2.8.0
androidx.navigation:navigation-common:2.8.0-beta06 androidx.navigation:navigation-common:2.8.0
androidx.navigation:navigation-compose:2.8.0-beta06 androidx.navigation:navigation-compose:2.8.0
androidx.navigation:navigation-runtime-ktx:2.8.0-beta06 androidx.navigation:navigation-runtime-ktx:2.8.0
androidx.navigation:navigation-runtime:2.8.0-beta06 androidx.navigation:navigation-runtime:2.8.0
androidx.print:print:1.0.0 androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05 androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05 androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05

@ -47,9 +47,13 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<data <action android:name="android.intent.action.VIEW" />
android:scheme="https"
android:host="www.nowinandroid.apps.samples.google.com" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="www.nowinandroid.apps.samples.google.com" />
</intent-filter> </intent-filter>
</activity> </activity>

@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
@ -40,12 +40,11 @@ fun NiaNavHost(
appState: NiaAppState, appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = FOR_YOU_ROUTE,
) { ) {
val navController = appState.navController val navController = appState.navController
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = ForYouRoute,
modifier = modifier, modifier = modifier,
) { ) {
forYouScreen(onTopicClick = navController::navigateToInterests) forYouScreen(onTopicClick = navController::navigateToInterests)

@ -16,9 +16,14 @@
package com.google.samples.apps.nowinandroid.navigation package com.google.samples.apps.nowinandroid.navigation
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.feature.bookmarks.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.R as searchR import com.google.samples.apps.nowinandroid.feature.search.R as searchR
@ -31,25 +36,29 @@ import com.google.samples.apps.nowinandroid.feature.search.R as searchR
enum class TopLevelDestination( enum class TopLevelDestination(
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val unselectedIcon: ImageVector, val unselectedIcon: ImageVector,
val iconTextId: Int, @StringRes val iconTextId: Int,
val titleTextId: Int, @StringRes val titleTextId: Int,
val route: KClass<*>,
) { ) {
FOR_YOU( FOR_YOU(
selectedIcon = NiaIcons.Upcoming, selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder, unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title, iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name, titleTextId = R.string.app_name,
route = ForYouRoute::class,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks, selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder, unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title, iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title, titleTextId = bookmarksR.string.feature_bookmarks_title,
route = BookmarksRoute::class,
), ),
INTERESTS( INTERESTS(
selectedIcon = NiaIcons.Grid3x3, selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3, unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests, iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests, titleTextId = searchR.string.feature_search_interests,
route = InterestsRoute::class,
), ),
} }

@ -60,6 +60,7 @@ 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
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
@ -72,6 +73,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradien
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@ -150,7 +152,7 @@ internal fun NiaApp(
appState.topLevelDestinations.forEach { destination -> appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination) val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination val selected = currentDestination
.isTopLevelDestinationInHierarchy(destination) .isRouteInHierarchy(destination.route)
item( item(
selected = selected, selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) }, onClick = { appState.navigateToTopLevelDestination(destination) },
@ -198,8 +200,10 @@ internal fun NiaApp(
) { ) {
// Show the top app bar on top level destinations. // Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination val destination = appState.currentTopLevelDestination
val shouldShowTopAppBar = destination != null var shouldShowTopAppBar = false
if (destination != null) { if (destination != null) {
shouldShowTopAppBar = true
NiaTopAppBar( NiaTopAppBar(
titleRes = destination.titleTextId, titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search, navigationIcon = NiaIcons.Search,
@ -266,7 +270,7 @@ private fun Modifier.notificationDot(): Modifier =
} }
} }
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
this?.hierarchy?.any { this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false it.hasRoute(route)
} ?: false } ?: false

@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@ -32,11 +33,8 @@ 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.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -90,11 +88,10 @@ class NiaAppState(
.currentBackStackEntryAsState().value?.destination .currentBackStackEntryAsState().value?.destination
val currentTopLevelDestination: TopLevelDestination? val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) { @Composable get() {
FOR_YOU_ROUTE -> FOR_YOU return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
BOOKMARKS_ROUTE -> BOOKMARKS currentDestination?.hasRoute(route = topLevelDestination.route) ?: false
INTERESTS_ROUTE -> INTERESTS }
else -> null
} }
val isOffline = networkMonitor.isOnline val isOffline = networkMonitor.isOnline

@ -18,19 +18,26 @@ package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
const val TOPIC_ID_KEY = "selectedTopicId"
@HiltViewModel @HiltViewModel
class Interests2PaneViewModel @Inject constructor( class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val selectedTopicId: StateFlow<String?> =
savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG]) val route = savedStateHandle.toRoute<InterestsRoute>()
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
key = TOPIC_ID_KEY,
initialValue = route.initialTopicId,
)
fun onTopicClick(topicId: String?) { fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId savedStateHandle[TOPIC_ID_KEY] = topicId
} }
} }

@ -39,34 +39,24 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.createTopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route" @Serializable internal object TopicPlaceholderRoute
@Serializable internal object DetailPaneNavHostRoute
fun NavGraphBuilder.interestsListDetailScreen() { fun NavGraphBuilder.interestsListDetailScreen() {
composable( composable<InterestsRoute> {
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
type = NavType.StringType
defaultValue = null
nullable = true
},
),
) {
InterestsListDetailScreen() InterestsListDetailScreen()
} }
} }
@ -104,8 +94,9 @@ internal fun InterestsListDetailScreen(
listDetailNavigator.navigateBack() listDetailNavigator.navigateBack()
} }
var nestedNavHostStartDestination by remember { var nestedNavHostStartRoute by remember {
mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE) val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
} }
var nestedNavKey by rememberSaveable( var nestedNavKey by rememberSaveable(
stateSaver = Saver({ it.toString() }, UUID::fromString), stateSaver = Saver({ it.toString() }, UUID::fromString),
@ -122,11 +113,11 @@ internal fun InterestsListDetailScreen(
// If the detail pane was visible, then use the nestedNavController navigate call // If the detail pane was visible, then use the nestedNavController navigate call
// directly // directly
nestedNavController.navigateToTopic(topicId) { nestedNavController.navigateToTopic(topicId) {
popUpTo(DETAIL_PANE_NAVHOST_ROUTE) popUpTo<DetailPaneNavHostRoute>()
} }
} else { } else {
// Otherwise, recreate the NavHost entirely, and start at the new destination // Otherwise, recreate the NavHost entirely, and start at the new destination
nestedNavHostStartDestination = createTopicRoute(topicId) nestedNavHostStartRoute = TopicRoute(id = topicId)
nestedNavKey = UUID.randomUUID() nestedNavKey = UUID.randomUUID()
} }
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
@ -148,15 +139,15 @@ internal fun InterestsListDetailScreen(
key(nestedNavKey) { key(nestedNavKey) {
NavHost( NavHost(
navController = nestedNavController, navController = nestedNavController,
startDestination = nestedNavHostStartDestination, startDestination = nestedNavHostStartRoute,
route = DETAIL_PANE_NAVHOST_ROUTE, route = DetailPaneNavHostRoute::class,
) { ) {
topicScreen( topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(), showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack, onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane, onTopicClick = ::onTopicClickShowDetailPane,
) )
composable(route = TOPIC_ROUTE) { composable<TopicPlaceholderRoute> {
TopicDetailPlaceholder() TopicDetailPlaceholder()
} }
} }

@ -28,6 +28,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
pluginManager.apply { pluginManager.apply {
apply("nowinandroid.android.library") apply("nowinandroid.android.library")
apply("nowinandroid.hilt") apply("nowinandroid.hilt")
apply("org.jetbrains.kotlin.plugin.serialization")
} }
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true testOptions.animationsDisabled = true
@ -41,8 +42,11 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get()) add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("androidx.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.tracing.ktx").get()) add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
add("implementation", libs.findLibrary("kotlinx.serialization.json").get())
add("testImplementation", libs.findLibrary("androidx.navigation.testing").get())
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get()) add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
} }
} }

@ -44,7 +44,10 @@ private const val NEWS_NOTIFICATION_SUMMARY_ID = 1
private const val NEWS_NOTIFICATION_CHANNEL_ID = "" private const val NEWS_NOTIFICATION_CHANNEL_ID = ""
private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS" private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS"
private const val DEEP_LINK_SCHEME_AND_HOST = "https://www.nowinandroid.apps.samples.google.com" private const val DEEP_LINK_SCHEME_AND_HOST = "https://www.nowinandroid.apps.samples.google.com"
private const val FOR_YOU_PATH = "foryou" private const val DEEP_LINK_FOR_YOU_PATH = "foryou"
private const val DEEP_LINK_BASE_PATH = "$DEEP_LINK_SCHEME_AND_HOST/$DEEP_LINK_FOR_YOU_PATH"
const val DEEP_LINK_NEWS_RESOURCE_ID_KEY = "linkedNewsResourceId"
const val DEEP_LINK_URI_PATTERN = "$DEEP_LINK_BASE_PATH/{$DEEP_LINK_NEWS_RESOURCE_ID_KEY}"
/** /**
* Implementation of [Notifier] that displays notifications in the system tray. * Implementation of [Notifier] that displays notifications in the system tray.
@ -161,4 +164,4 @@ private fun Context.newsPendingIntent(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_SCHEME_AND_HOST/$FOR_YOU_PATH/$id".toUri() private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_BASE_PATH/$id".toUri()

@ -21,16 +21,18 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
import kotlinx.serialization.Serializable
const val BOOKMARKS_ROUTE = "bookmarks_route" @Serializable object BookmarksRoute
fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(BOOKMARKS_ROUTE, navOptions) fun NavController.navigateToBookmarks(navOptions: NavOptions) =
navigate(route = BookmarksRoute, navOptions)
fun NavGraphBuilder.bookmarksScreen( fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
) { ) {
composable(route = BOOKMARKS_ROUTE) { composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar) BookmarksRoute(onTopicClick, onShowSnackbar)
} }
} }

@ -29,6 +29,7 @@ dependencies {
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(project(":core:notifications"))
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)

@ -106,7 +106,7 @@ import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable @Composable
internal fun ForYouRoute( internal fun ForYouScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(), viewModel: ForYouViewModel = hiltViewModel(),

@ -27,8 +27,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
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.SyncManager import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
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.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -55,7 +55,7 @@ class ForYouViewModel @Inject constructor(
userDataRepository.userData.map { !it.shouldHideOnboarding } userDataRepository.userData.map { !it.shouldHideOnboarding }
val deepLinkedNewsResource = savedStateHandle.getStateFlow<String?>( val deepLinkedNewsResource = savedStateHandle.getStateFlow<String?>(
key = LINKED_NEWS_RESOURCE_ID, key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
null, null,
) )
.flatMapLatest { newsResourceId -> .flatMapLatest { newsResourceId ->
@ -129,7 +129,7 @@ class ForYouViewModel @Inject constructor(
fun onDeepLinkOpened(newsResourceId: String) { fun onDeepLinkOpened(newsResourceId: String) {
if (newsResourceId == deepLinkedNewsResource.value?.id) { if (newsResourceId == deepLinkedNewsResource.value?.id) {
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = null savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = null
} }
analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId) analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId)
viewModelScope.launch { viewModelScope.launch {
@ -153,7 +153,7 @@ private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) =
type = "news_deep_link_opened", type = "news_deep_link_opened",
extras = listOf( extras = listOf(
Param( Param(
key = LINKED_NEWS_RESOURCE_ID, key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = newsResourceId, value = newsResourceId,
), ),
), ),

@ -19,29 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink import androidx.navigation.navDeepLink
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_URI_PATTERN
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouScreen
import kotlinx.serialization.Serializable
const val LINKED_NEWS_RESOURCE_ID = "linkedNewsResourceId" @Serializable data object ForYouRoute
const val FOR_YOU_ROUTE = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}"
private const val DEEP_LINK_URI_PATTERN =
"https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}"
fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(FOR_YOU_ROUTE, navOptions) fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute, navOptions)
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) { fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable( composable<ForYouRoute>(
route = FOR_YOU_ROUTE,
deepLinks = listOf( deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN }, navDeepLink {
), /**
arguments = listOf( * This destination has a deep link that enables a specific news resource to be
navArgument(LINKED_NEWS_RESOURCE_ID) { type = NavType.StringType }, * 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
},
), ),
) { ) {
ForYouRoute(onTopicClick) ForYouScreen(onTopicClick)
} }
} }

@ -26,6 +26,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
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.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
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.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -34,7 +35,6 @@ import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestAnalyticsHelper import com.google.samples.apps.nowinandroid.core.testing.util.TestAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager
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.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -472,7 +472,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(emptyUserData) userDataRepository.setUserData(emptyUserData)
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = sampleNewsResources.first().id savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = sampleNewsResources.first().id
assertEquals( assertEquals(
expected = UserNewsResource( expected = UserNewsResource(
@ -496,7 +496,7 @@ class ForYouViewModelTest {
type = "news_deep_link_opened", type = "news_deep_link_opened",
extras = listOf( extras = listOf(
Param( Param(
key = LINKED_NEWS_RESOURCE_ID, key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = sampleNewsResources.first().id, value = sampleNewsResources.first().id,
), ),
), ),

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.domain) implementation(projects.core.domain)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing) androidTestImplementation(projects.core.testing)

@ -19,11 +19,12 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,7 +40,14 @@ class InterestsViewModel @Inject constructor(
getFollowableTopics: GetFollowableTopicsUseCase, getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() { ) : ViewModel() {
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null) // Key used to save and retrieve the currently selected topic id from saved state.
private val selectedTopicIdKey = "selectedTopicIdKey"
private val interestsRoute: InterestsRoute = savedStateHandle.toRoute()
private val selectedTopicId = savedStateHandle.getStateFlow(
key = selectedTopicIdKey,
initialValue = interestsRoute.initialTopicId,
)
val uiState: StateFlow<InterestsUiState> = combine( val uiState: StateFlow<InterestsUiState> = combine(
selectedTopicId, selectedTopicId,
@ -58,7 +66,7 @@ class InterestsViewModel @Inject constructor(
} }
fun onTopicClick(topicId: String?) { fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId savedStateHandle[selectedTopicIdKey] = topicId
} }
} }

@ -17,39 +17,17 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.NavType import kotlinx.serialization.Serializable
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
const val TOPIC_ID_ARG = "topicId" @Serializable data class InterestsRoute(
const val INTERESTS_ROUTE_BASE = "interests_route" // The ID of the topic which will be initially selected at this destination
const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}" val initialTopicId: String? = null,
)
fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) { fun NavController.navigateToInterests(
val route = if (topicId != null) { initialTopicId: String? = null,
"${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId" navOptions: NavOptions? = null,
} else {
INTERESTS_ROUTE_BASE
}
navigate(route, navOptions)
}
fun NavGraphBuilder.interestsScreen(
onTopicClick: (String) -> Unit,
) { ) {
composable( navigate(route = InterestsRoute(initialTopicId), navOptions)
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
defaultValue = null
nullable = true
type = NavType.StringType
},
),
) {
InterestsRoute(onTopicClick = onTopicClick)
}
} }

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.navigation.testing.invoke
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -25,7 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -33,12 +34,21 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals import kotlin.test.assertEquals
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein * https://developer.android.com/kotlin/flow/test#statein
*
* These tests use Robolectric because the subject under test (the ViewModel) uses
* `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`.
*
* TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency.
* See https://issuetracker.google.com/340966212.
*/ */
@RunWith(RobolectricTestRunner::class)
class InterestsViewModelTest { class InterestsViewModelTest {
@get:Rule @get:Rule
@ -55,7 +65,9 @@ class InterestsViewModelTest {
@Before @Before
fun setup() { fun setup() {
viewModel = InterestsViewModel( viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), savedStateHandle = SavedStateHandle(
route = InterestsRoute(initialTopicId = testInputTopics[0].topic.id),
),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
) )

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.data) implementation(projects.core.data)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing) androidTestImplementation(projects.core.testing)

@ -71,7 +71,7 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string import com.google.samples.apps.nowinandroid.feature.topic.R.string
@Composable @Composable
internal fun TopicRoute( internal fun TopicScreen(
showBackButton: Boolean, showBackButton: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
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.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
@ -28,7 +29,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -47,12 +48,10 @@ class TopicViewModel @Inject constructor(
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() { ) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle) val topicId = savedStateHandle.toRoute<TopicRoute>().id
val topicId = topicArgs.topicId
val topicUiState: StateFlow<TopicUiState> = topicUiState( val topicUiState: StateFlow<TopicUiState> = topicUiState(
topicId = topicArgs.topicId, topicId = topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
) )
@ -63,7 +62,7 @@ class TopicViewModel @Inject constructor(
) )
val newsUiState: StateFlow<NewsUiState> = newsUiState( val newsUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicArgs.topicId, topicId = topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
@ -75,7 +74,7 @@ class TopicViewModel @Inject constructor(
fun followTopicToggle(followed: Boolean) { fun followTopicToggle(followed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.setTopicIdFollowed(topicArgs.topicId, followed) userDataRepository.setTopicIdFollowed(topicId, followed)
} }
} }

@ -16,53 +16,28 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation package com.google.samples.apps.nowinandroid.feature.topic.navigation
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute import kotlinx.serialization.Serializable
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.text.Charsets.UTF_8
private val URL_CHARACTER_ENCODING = UTF_8.name() @Serializable data class TopicRoute(val id: String)
@VisibleForTesting
internal const val TOPIC_ID_ARG = "topicId"
const val TOPIC_ROUTE = "topic_route"
internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle) :
this(URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING))
}
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
navigate(createTopicRoute(topicId)) { navigate(route = TopicRoute(topicId)) {
navOptions() navOptions()
} }
} }
fun createTopicRoute(topicId: String): String {
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
return "$TOPIC_ROUTE/$encodedId"
}
fun NavGraphBuilder.topicScreen( fun NavGraphBuilder.topicScreen(
showBackButton: Boolean, showBackButton: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
composable( composable<TopicRoute> {
route = "topic_route/{$TOPIC_ID_ARG}", TopicScreen(
arguments = listOf(
navArgument(TOPIC_ID_ARG) { type = NavType.StringType },
),
) {
TopicRoute(
showBackButton = showBackButton, showBackButton = showBackButton,
onBackClick = onBackClick, onBackClick = onBackClick,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.navigation.testing.invoke
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.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -25,7 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ID_ARG import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -36,13 +37,22 @@ import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein * https://developer.android.com/kotlin/flow/test#statein
*
* These tests use Robolectric because the subject under test (the ViewModel) uses
* `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`.
*
* TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency.
* * See b/340966212.
*/ */
@RunWith(RobolectricTestRunner::class)
class TopicViewModelTest { class TopicViewModelTest {
@get:Rule @get:Rule
@ -60,7 +70,9 @@ class TopicViewModelTest {
@Before @Before
fun setup() { fun setup() {
viewModel = TopicViewModel( viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), savedStateHandle = SavedStateHandle(
route = TopicRoute(id = testInputTopics[0].topic.id),
),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,

@ -20,7 +20,7 @@ androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.8.3" androidxLifecycle = "2.8.3"
androidxMacroBenchmark = "1.2.4" androidxMacroBenchmark = "1.2.4"
androidxMetrics = "1.0.0-alpha04" androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.8.0-beta06" androidxNavigation = "2.8.0"
androidxProfileinstaller = "1.3.1" androidxProfileinstaller = "1.3.1"
androidxTestCore = "1.5.0" androidxTestCore = "1.5.0"
androidxTestExt = "1.1.5" androidxTestExt = "1.1.5"

Loading…
Cancel
Save