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"