diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9c6989f67..9b577a5bc 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -25,6 +25,7 @@ plugins {
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi)
+ alias(libs.plugins.kotlin.serialization)
}
android {
@@ -103,6 +104,7 @@ dependencies {
implementation(libs.androidx.window.core)
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt)
+ implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler)
diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt
index b41e3b9e8..54a396dff 100644
--- a/app/dependencies/prodReleaseRuntimeClasspath.txt
+++ b/app/dependencies/prodReleaseRuntimeClasspath.txt
@@ -2,25 +2,25 @@ androidx.activity:activity-compose:1.8.2
androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2
androidx.annotation:annotation-experimental:1.4.1
-androidx.annotation:annotation-jvm:1.8.0
-androidx.annotation:annotation:1.8.0
+androidx.annotation:annotation-jvm:1.8.1
+androidx.annotation:annotation:1.8.1
androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0
-androidx.collection:collection-jvm:1.4.0
-androidx.collection:collection-ktx:1.4.0
-androidx.collection:collection:1.4.0
-androidx.compose.animation:animation-android:1.7.0-rc01
-androidx.compose.animation:animation-core-android:1.7.0-rc01
-androidx.compose.animation:animation-core:1.7.0-rc01
-androidx.compose.animation:animation:1.7.0-rc01
-androidx.compose.foundation:foundation-android:1.7.0-rc01
-androidx.compose.foundation:foundation-layout-android:1.7.0-rc01
-androidx.compose.foundation:foundation-layout:1.7.0-rc01
-androidx.compose.foundation:foundation:1.7.0-rc01
+androidx.collection:collection-jvm:1.4.2
+androidx.collection:collection-ktx:1.4.2
+androidx.collection:collection:1.4.2
+androidx.compose.animation:animation-android:1.7.0
+androidx.compose.animation:animation-core-android:1.7.0
+androidx.compose.animation:animation-core:1.7.0
+androidx.compose.animation:animation:1.7.0
+androidx.compose.foundation:foundation-android:1.7.0
+androidx.compose.foundation:foundation-layout-android:1.7.0
+androidx.compose.foundation:foundation-layout:1.7.0
+androidx.compose.foundation:foundation:1.7.0
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: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-ripple-android: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-saveable-android:1.7.0-rc01
-androidx.compose.runtime:runtime-saveable:1.7.0-rc01
+androidx.compose.runtime:runtime-android:1.7.0
+androidx.compose.runtime:runtime-saveable-android:1.7.0
+androidx.compose.runtime:runtime-saveable:1.7.0
androidx.compose.runtime:runtime-tracing:1.0.0-beta01
-androidx.compose.runtime:runtime:1.7.0-rc01
-androidx.compose.ui:ui-android:1.7.0-rc01
-androidx.compose.ui:ui-geometry-android:1.7.0-rc01
-androidx.compose.ui:ui-geometry:1.7.0-rc01
-androidx.compose.ui:ui-graphics-android:1.7.0-rc01
-androidx.compose.ui:ui-graphics:1.7.0-rc01
-androidx.compose.ui:ui-text-android:1.7.0-rc01
-androidx.compose.ui:ui-text:1.7.0-rc01
-androidx.compose.ui:ui-tooling-preview-android:1.7.0-rc01
-androidx.compose.ui:ui-tooling-preview:1.7.0-rc01
-androidx.compose.ui:ui-unit-android:1.7.0-rc01
-androidx.compose.ui:ui-unit:1.7.0-rc01
-androidx.compose.ui:ui-util-android:1.7.0-rc01
-androidx.compose.ui:ui-util:1.7.0-rc01
-androidx.compose.ui:ui:1.7.0-rc01
+androidx.compose.runtime:runtime:1.7.0
+androidx.compose.ui:ui-android:1.7.0
+androidx.compose.ui:ui-geometry-android:1.7.0
+androidx.compose.ui:ui-geometry:1.7.0
+androidx.compose.ui:ui-graphics-android:1.7.0
+androidx.compose.ui:ui-graphics:1.7.0
+androidx.compose.ui:ui-text-android:1.7.0
+androidx.compose.ui:ui-text:1.7.0
+androidx.compose.ui:ui-tooling-preview-android:1.7.0
+androidx.compose.ui:ui-tooling-preview:1.7.0
+androidx.compose.ui:ui-unit-android:1.7.0
+androidx.compose.ui:ui-unit:1.7.0
+androidx.compose.ui:ui-util-android:1.7.0
+androidx.compose.ui:ui-util:1.7.0
+androidx.compose.ui:ui:1.7.0
androidx.compose:compose-bom:2024.02.02
androidx.concurrent:concurrent-futures:1.1.0
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.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
-androidx.navigation:navigation-common-ktx:2.8.0-beta06
-androidx.navigation:navigation-common:2.8.0-beta06
-androidx.navigation:navigation-compose:2.8.0-beta06
-androidx.navigation:navigation-runtime-ktx:2.8.0-beta06
-androidx.navigation:navigation-runtime:2.8.0-beta06
+androidx.navigation:navigation-common-ktx:2.8.0
+androidx.navigation:navigation-common:2.8.0
+androidx.navigation:navigation-compose:2.8.0
+androidx.navigation:navigation-runtime-ktx:2.8.0
+androidx.navigation:navigation-runtime:2.8.0
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 716305ab6..5b22f9865 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -47,9 +47,13 @@
-
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt
index 39bc03de7..f878c003b 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
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.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
@@ -40,12 +40,11 @@ fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
- startDestination: String = FOR_YOU_ROUTE,
) {
val navController = appState.navController
NavHost(
navController = navController,
- startDestination = startDestination,
+ startDestination = ForYouRoute,
modifier = modifier,
) {
forYouScreen(onTopicClick = navController::navigateToInterests)
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt
index aca7d54ab..815061273 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt
@@ -16,9 +16,14 @@
package com.google.samples.apps.nowinandroid.navigation
+import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
+import com.google.samples.apps.nowinandroid.feature.bookmarks.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.foryou.R as forYouR
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(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
- val iconTextId: Int,
- val titleTextId: Int,
+ @StringRes val iconTextId: Int,
+ @StringRes val titleTextId: Int,
+ val route: KClass<*>,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
+ route = ForYouRoute::class,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title,
+ route = BookmarksRoute::class,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests,
+ route = InterestsRoute::class,
),
}
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
index b47984ddb..6cdc32bb0 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
@@ -60,6 +60,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R
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.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
+import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -150,7 +152,7 @@ internal fun NiaApp(
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
- .isTopLevelDestinationInHierarchy(destination)
+ .isRouteInHierarchy(destination.route)
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
@@ -198,8 +200,10 @@ internal fun NiaApp(
) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
- val shouldShowTopAppBar = destination != null
+ var shouldShowTopAppBar = false
+
if (destination != null) {
+ shouldShowTopAppBar = true
NiaTopAppBar(
titleRes = destination.titleTextId,
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 {
- it.route?.contains(destination.name, true) ?: false
+ it.hasRoute(route)
} ?: false
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt
index 519603579..75a294c01 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt
@@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
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.TimeZoneMonitor
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.foryou.navigation.FOR_YOU_ROUTE
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.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@@ -90,11 +88,10 @@ class NiaAppState(
.currentBackStackEntryAsState().value?.destination
val currentTopLevelDestination: TopLevelDestination?
- @Composable get() = when (currentDestination?.route) {
- FOR_YOU_ROUTE -> FOR_YOU
- BOOKMARKS_ROUTE -> BOOKMARKS
- INTERESTS_ROUTE -> INTERESTS
- else -> null
+ @Composable get() {
+ return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
+ currentDestination?.hasRoute(route = topLevelDestination.route) ?: false
+ }
}
val isOffline = networkMonitor.isOnline
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt
index 40ce9c116..3d37f3417 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt
@@ -18,19 +18,26 @@ package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
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 kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
+const val TOPIC_ID_KEY = "selectedTopicId"
+
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
- val selectedTopicId: StateFlow =
- savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG])
+
+ val route = savedStateHandle.toRoute()
+ val selectedTopicId: StateFlow = savedStateHandle.getStateFlow(
+ key = TOPIC_ID_KEY,
+ initialValue = route.initialTopicId,
+ )
fun onTopicClick(topicId: String?) {
- savedStateHandle[TOPIC_ID_ARG] = topicId
+ savedStateHandle[TOPIC_ID_KEY] = topicId
}
}
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt
index 919cb44f2..27f0c2e1e 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt
@@ -39,34 +39,24 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
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.navigation.INTERESTS_ROUTE
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
+import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
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.createTopicRoute
+import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
+import kotlinx.serialization.Serializable
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() {
- composable(
- route = INTERESTS_ROUTE,
- arguments = listOf(
- navArgument(TOPIC_ID_ARG) {
- type = NavType.StringType
- defaultValue = null
- nullable = true
- },
- ),
- ) {
+ composable {
InterestsListDetailScreen()
}
}
@@ -104,8 +94,9 @@ internal fun InterestsListDetailScreen(
listDetailNavigator.navigateBack()
}
- var nestedNavHostStartDestination by remember {
- mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE)
+ var nestedNavHostStartRoute by remember {
+ val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
+ mutableStateOf(route)
}
var nestedNavKey by rememberSaveable(
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
// directly
nestedNavController.navigateToTopic(topicId) {
- popUpTo(DETAIL_PANE_NAVHOST_ROUTE)
+ popUpTo()
}
} else {
// Otherwise, recreate the NavHost entirely, and start at the new destination
- nestedNavHostStartDestination = createTopicRoute(topicId)
+ nestedNavHostStartRoute = TopicRoute(id = topicId)
nestedNavKey = UUID.randomUUID()
}
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
@@ -148,15 +139,15 @@ internal fun InterestsListDetailScreen(
key(nestedNavKey) {
NavHost(
navController = nestedNavController,
- startDestination = nestedNavHostStartDestination,
- route = DETAIL_PANE_NAVHOST_ROUTE,
+ startDestination = nestedNavHostStartRoute,
+ route = DetailPaneNavHostRoute::class,
) {
topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane,
)
- composable(route = TOPIC_ROUTE) {
+ composable {
TopicDetailPlaceholder()
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
index 9110e7fa3..6d0f213d4 100644
--- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
@@ -28,6 +28,7 @@ class AndroidFeatureConventionPlugin : Plugin {
pluginManager.apply {
apply("nowinandroid.android.library")
apply("nowinandroid.hilt")
+ apply("org.jetbrains.kotlin.plugin.serialization")
}
extensions.configure {
testOptions.animationsDisabled = true
@@ -41,8 +42,11 @@ class AndroidFeatureConventionPlugin : Plugin {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").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("kotlinx.serialization.json").get())
+ add("testImplementation", libs.findLibrary("androidx.navigation.testing").get())
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
}
}
diff --git a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
index 5da88102a..3fc8114dd 100644
--- a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
+++ b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
@@ -44,7 +44,10 @@ private const val NEWS_NOTIFICATION_SUMMARY_ID = 1
private const val NEWS_NOTIFICATION_CHANNEL_ID = ""
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 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.
@@ -161,4 +164,4 @@ private fun Context.newsPendingIntent(
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()
diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt
index 13d0baef0..ea8d525ab 100644
--- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt
+++ b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt
@@ -21,16 +21,18 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
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(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
- composable(route = BOOKMARKS_ROUTE) {
+ composable {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
}
diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts
index 004fe8ad6..41d5b16a2 100644
--- a/feature/foryou/build.gradle.kts
+++ b/feature/foryou/build.gradle.kts
@@ -29,6 +29,7 @@ dependencies {
implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain)
+ implementation(project(":core:notifications"))
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
index 885020636..0f345aa80 100644
--- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
+++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
@@ -106,7 +106,7 @@ import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable
-internal fun ForYouRoute(
+internal fun ForYouScreen(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(),
diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
index 85035a77a..4b6cd39c9 100644
--- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
+++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
@@ -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.util.SyncManager
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.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -55,7 +55,7 @@ class ForYouViewModel @Inject constructor(
userDataRepository.userData.map { !it.shouldHideOnboarding }
val deepLinkedNewsResource = savedStateHandle.getStateFlow(
- key = LINKED_NEWS_RESOURCE_ID,
+ key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
null,
)
.flatMapLatest { newsResourceId ->
@@ -129,7 +129,7 @@ class ForYouViewModel @Inject constructor(
fun onDeepLinkOpened(newsResourceId: String) {
if (newsResourceId == deepLinkedNewsResource.value?.id) {
- savedStateHandle[LINKED_NEWS_RESOURCE_ID] = null
+ savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = null
}
analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId)
viewModelScope.launch {
@@ -153,7 +153,7 @@ private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) =
type = "news_deep_link_opened",
extras = listOf(
Param(
- key = LINKED_NEWS_RESOURCE_ID,
+ key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = newsResourceId,
),
),
diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
index 8e94a491a..9d98f1618 100644
--- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
+++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
@@ -19,29 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
-import androidx.navigation.NavType
import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
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"
-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}"
+@Serializable data object ForYouRoute
-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) {
- composable(
- route = FOR_YOU_ROUTE,
+ composable(
deepLinks = listOf(
- navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
- ),
- arguments = listOf(
- navArgument(LINKED_NEWS_RESOURCE_ID) { type = NavType.StringType },
+ 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
+ },
),
) {
- ForYouRoute(onTopicClick)
+ ForYouScreen(onTopicClick)
}
}
diff --git a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
index 2fbdf0a79..eece140ac 100644
--- a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
+++ b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
@@ -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.UserNewsResource
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.TestTopicsRepository
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.TestSyncManager
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.first
import kotlinx.coroutines.launch
@@ -472,7 +472,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(emptyUserData)
- savedStateHandle[LINKED_NEWS_RESOURCE_ID] = sampleNewsResources.first().id
+ savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = sampleNewsResources.first().id
assertEquals(
expected = UserNewsResource(
@@ -496,7 +496,7 @@ class ForYouViewModelTest {
type = "news_deep_link_opened",
extras = listOf(
Param(
- key = LINKED_NEWS_RESOURCE_ID,
+ key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = sampleNewsResources.first().id,
),
),
diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts
index ca91ba2c4..2b84b135f 100644
--- a/feature/interests/build.gradle.kts
+++ b/feature/interests/build.gradle.kts
@@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.domain)
testImplementation(projects.core.testing)
+ testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
index b369ac5ab..67cc8884f 100644
--- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
+++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
@@ -19,11 +19,12 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
+import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -39,7 +40,14 @@ class InterestsViewModel @Inject constructor(
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
- val selectedTopicId: StateFlow = 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 = combine(
selectedTopicId,
@@ -58,7 +66,7 @@ class InterestsViewModel @Inject constructor(
}
fun onTopicClick(topicId: String?) {
- savedStateHandle[TOPIC_ID_ARG] = topicId
+ savedStateHandle[selectedTopicIdKey] = topicId
}
}
diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt
index 8a0f2d130..d83e4a9b2 100644
--- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt
+++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt
@@ -17,39 +17,17 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.navigation.NavController
-import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
+import kotlinx.serialization.Serializable
-const val TOPIC_ID_ARG = "topicId"
-const val INTERESTS_ROUTE_BASE = "interests_route"
-const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}"
+@Serializable data class InterestsRoute(
+ // The ID of the topic which will be initially selected at this destination
+ val initialTopicId: String? = null,
+)
-fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) {
- val route = if (topicId != null) {
- "${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId"
- } else {
- INTERESTS_ROUTE_BASE
- }
- navigate(route, navOptions)
-}
-
-fun NavGraphBuilder.interestsScreen(
- onTopicClick: (String) -> Unit,
+fun NavController.navigateToInterests(
+ initialTopicId: String? = null,
+ navOptions: NavOptions? = null,
) {
- composable(
- route = INTERESTS_ROUTE,
- arguments = listOf(
- navArgument(TOPIC_ID_ARG) {
- defaultValue = null
- nullable = true
- type = NavType.StringType
- },
- ),
- ) {
- InterestsRoute(onTopicClick = onTopicClick)
- }
+ navigate(route = InterestsRoute(initialTopicId), navOptions)
}
diff --git a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
index 63d3c49b7..987a5bc01 100644
--- a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
+++ b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
@@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.interests
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.model.data.FollowableTopic
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.feature.interests.InterestsUiState
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.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -33,12 +34,21 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
/**
* To learn more about how this test handles Flows created with stateIn, see
* 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 {
@get:Rule
@@ -55,7 +65,9 @@ class InterestsViewModelTest {
@Before
fun setup() {
viewModel = InterestsViewModel(
- savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
+ savedStateHandle = SavedStateHandle(
+ route = InterestsRoute(initialTopicId = testInputTopics[0].topic.id),
+ ),
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
)
diff --git a/feature/topic/build.gradle.kts b/feature/topic/build.gradle.kts
index 726920af1..bd8b59ec8 100644
--- a/feature/topic/build.gradle.kts
+++ b/feature/topic/build.gradle.kts
@@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.data)
testImplementation(projects.core.testing)
+ testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
index 5ac766675..13fbab784 100644
--- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
+++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
@@ -71,7 +71,7 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
@Composable
-internal fun TopicRoute(
+internal fun TopicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
index 255e40f8b..ba8baad14 100644
--- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
+++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
@@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.TopicsRepository
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.result.Result
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -47,12 +48,10 @@ class TopicViewModel @Inject constructor(
userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() {
- private val topicArgs: TopicArgs = TopicArgs(savedStateHandle)
-
- val topicId = topicArgs.topicId
+ val topicId = savedStateHandle.toRoute().id
val topicUiState: StateFlow = topicUiState(
- topicId = topicArgs.topicId,
+ topicId = topicId,
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
)
@@ -63,7 +62,7 @@ class TopicViewModel @Inject constructor(
)
val newsUiState: StateFlow = newsUiState(
- topicId = topicArgs.topicId,
+ topicId = topicId,
userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository,
)
@@ -75,7 +74,7 @@ class TopicViewModel @Inject constructor(
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {
- userDataRepository.setTopicIdFollowed(topicArgs.topicId, followed)
+ userDataRepository.setTopicIdFollowed(topicId, followed)
}
}
diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt
index 394c53303..fabb82b10 100644
--- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt
+++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt
@@ -16,53 +16,28 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation
-import androidx.annotation.VisibleForTesting
-import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder
-import androidx.navigation.NavType
import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
-import java.net.URLDecoder
-import java.net.URLEncoder
-import kotlin.text.Charsets.UTF_8
+import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
+import kotlinx.serialization.Serializable
-private val URL_CHARACTER_ENCODING = UTF_8.name()
-
-@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))
-}
+@Serializable data class TopicRoute(val id: String)
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
- navigate(createTopicRoute(topicId)) {
+ navigate(route = TopicRoute(topicId)) {
navOptions()
}
}
-fun createTopicRoute(topicId: String): String {
- val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
- return "$TOPIC_ROUTE/$encodedId"
-}
-
fun NavGraphBuilder.topicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
- composable(
- route = "topic_route/{$TOPIC_ID_ARG}",
- arguments = listOf(
- navArgument(TOPIC_ID_ARG) { type = NavType.StringType },
- ),
- ) {
- TopicRoute(
+ composable {
+ TopicScreen(
showBackButton = showBackButton,
onBackClick = onBackClick,
onTopicClick = onTopicClick,
diff --git a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
index 565732f59..c14e62e31 100644
--- a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
+++ b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
@@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.topic
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.model.data.FollowableTopic
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.TestUserDataRepository
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.combine
import kotlinx.coroutines.flow.first
@@ -36,13 +37,22 @@ import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* To learn more about how this test handles Flows created with stateIn, see
* 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 {
@get:Rule
@@ -60,7 +70,9 @@ class TopicViewModelTest {
@Before
fun setup() {
viewModel = TopicViewModel(
- savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
+ savedStateHandle = SavedStateHandle(
+ route = TopicRoute(id = testInputTopics[0].topic.id),
+ ),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a2210bd4c..e5f671ef4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,7 +20,7 @@ androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.8.3"
androidxMacroBenchmark = "1.2.4"
androidxMetrics = "1.0.0-alpha04"
-androidxNavigation = "2.8.0-beta06"
+androidxNavigation = "2.8.0"
androidxProfileinstaller = "1.3.1"
androidxTestCore = "1.5.0"
androidxTestExt = "1.1.5"