Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type-safe navigation #1413

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6dc59e7
Use SafeArgs navigation for Topic feature
dturner May 1, 2024
d96bcf8
Migrate Interests nested NavHost to safe args
dturner May 3, 2024
4909972
Update to alpha08, use toRoute to obtain destination inside VM
dturner May 3, 2024
89163b5
Migrate remainder of app to type-safe navigation
dturner May 3, 2024
9779074
Remove Interests2PaneViewModel as it was overkill for holding a singl…
dturner May 3, 2024
29e08ea
Add deeplink action to enable testing from terminal
dturner May 3, 2024
2d45b84
Merge branch 'main' into dt/nav-safe-args
dturner May 3, 2024
924391c
Fix spotless
dturner May 3, 2024
0f926ba
🤖 Updates baselines for Dependency Guard
dturner May 3, 2024
8cc0fc0
🤖 Updates screenshots
dturner May 3, 2024
116e961
Change startDestination from KClass to default instance
dturner May 3, 2024
2264451
Use navigation argument topicId as default value to InterestListDetai…
dturner May 3, 2024
7ec21d9
Fix issue where selected topic in list was not showing as selected. M…
dturner May 7, 2024
f67f4d1
Update ForYou destination to match current deeplink pattern
dturner May 8, 2024
93a48a2
Use version catalog reference to serialization plugin
dturner May 8, 2024
173ac67
Remove autoVerify from intent filter
dturner May 8, 2024
3cff2fb
Remove unnecessary comment
dturner May 8, 2024
4a84cf2
Add specific type for deeplinks
dturner May 8, 2024
f711e69
Merge branch 'main' into dt/nav-safe-args (AnimatedPane broken)
dturner May 9, 2024
68152e5
Update to adaptive alpha12, fix merge issues
dturner May 14, 2024
aba2b2c
Remove different deeplink destination, add route for nested nav host
dturner May 14, 2024
be752e7
Fix tests, fix spotless
dturner May 14, 2024
8fc2e15
Update to navigation 2.8.0-beta01
dturner May 16, 2024
041e46e
Merge branch 'main' into dt/nav-safe-args
dturner May 30, 2024
da8f32a
Rename Destinations to Routes
dturner May 30, 2024
a6397b7
Merge branch 'main' into dt/nav-safe-args
dturner May 30, 2024
b73ee6d
🤖 Updates baselines for Dependency Guard
dturner May 30, 2024
95bdc0f
Rename ForYouRoute composable to ForYouScreen
dturner May 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 5 additions & 5 deletions app/dependencies/prodReleaseRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ androidx.lifecycle:lifecycle-viewmodel:2.8.0
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-alpha06
androidx.navigation:navigation-common:2.8.0-alpha06
androidx.navigation:navigation-compose:2.8.0-alpha06
androidx.navigation:navigation-runtime-ktx:2.8.0-alpha06
androidx.navigation:navigation-runtime:2.8.0-alpha06
androidx.navigation:navigation-common-ktx:2.8.0-beta01
androidx.navigation:navigation-common:2.8.0-beta01
androidx.navigation:navigation-compose:2.8.0-beta01
androidx.navigation:navigation-runtime-ktx:2.8.0-beta01
androidx.navigation:navigation-runtime:2.8.0-beta01
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
Expand Down
10 changes: 7 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<data
android:scheme="https"
android:host="www.nowinandroid.apps.samples.google.com" />
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data android:scheme="https" />
<data android:host="www.nowinandroid.apps.samples.google.com" />
</intent-filter>
</activity>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ package com.google.samples.apps.nowinandroid.navigation
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
Expand All @@ -33,23 +37,27 @@ enum class TopLevelDestination(
val unselectedIcon: ImageVector,
val iconTextId: Int,
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,
),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -268,5 +269,5 @@ private fun Modifier.notificationDot(): Modifier =

private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false
it.hasRoute(destination.route)
} ?: false
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,11 +33,11 @@ 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.BookmarksRoute
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.ForYouRoute
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.InterestsRoute
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
Expand Down Expand Up @@ -90,11 +91,13 @@ 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() {
with(currentDestination) {
if (this?.hasRoute<ForYouRoute>() == true) return FOR_YOU
if (this?.hasRoute<BookmarksRoute>() == true) return BOOKMARKS
if (this?.hasRoute<InterestsRoute>() == true) return INTERESTS
}
return null
}

val isOffline = networkMonitor.isOnline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,27 @@ 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 com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_KEY
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<String?> =
savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG])

val route = savedStateHandle.toRoute<InterestsRoute>()
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
key = TOPIC_ID_KEY,
initialValue = route.initialTopicId,
)

fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId
savedStateHandle[TOPIC_ID_KEY] = topicId
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,34 +36,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<InterestsRoute> {
InterestsListDetailScreen()
}
}
Expand Down Expand Up @@ -97,8 +87,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),
Expand All @@ -115,11 +106,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<DetailPaneNavHostRoute>()
}
} 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)
Expand All @@ -141,15 +132,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<TopicPlaceholderRoute> {
TopicDetailPlaceholder()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
pluginManager.apply {
apply("nowinandroid.android.library")
apply("nowinandroid.android.hilt")
apply("org.jetbrains.kotlin.plugin.serialization")
}
extensions.configure<LibraryExtension> {
defaultConfig {
Expand All @@ -45,8 +46,11 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
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())
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ private const val NEWS_NOTIFICATION_REQUEST_CODE = 0
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"
const val DEEP_LINK_SCHEME_AND_HOST = "https://www.nowinandroid.apps.samples.google.com"
const val FOR_YOU_PATH = "foryou"

/**
* Implementation of [Notifier] that displays notifications in the system tray.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
}
1 change: 1 addition & 0 deletions feature/foryou/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,27 @@ 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_SCHEME_AND_HOST
import com.google.samples.apps.nowinandroid.core.notifications.FOR_YOU_PATH
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}"

fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(FOR_YOU_ROUTE, navOptions)
private const val DEEP_LINK_BASE_PATH = "$DEEP_LINK_SCHEME_AND_HOST/$FOR_YOU_PATH"

@Serializable data class ForYouRoute(val linkedNewsResourceId: String? = null)

fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute(), navOptions)

fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable(
route = FOR_YOU_ROUTE,
composable<ForYouRoute>(
deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
),
arguments = listOf(
navArgument(LINKED_NEWS_RESOURCE_ID) { type = NavType.StringType },
navDeepLink<ForYouRoute>(basePath = DEEP_LINK_BASE_PATH),
),
) {
ForYouRoute(onTopicClick)
ForYouScreen(onTopicClick)
}
}