Merge "Updates Navigation approach with new guidance" into main

pull/347/head
Manuel Vicente Vivo 2 years ago committed by Gerrit Code Review
commit 0810686f48

@ -85,7 +85,6 @@ dependencies {
implementation(project(":core:ui"))
implementation(project(":core:designsystem"))
implementation(project(":core:navigation"))
implementation(project(":sync:work"))
implementation(project(":sync:sync-test"))
@ -104,6 +103,8 @@ dependencies {
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.window.manager)
implementation(libs.androidx.profileinstaller)
@ -118,4 +119,4 @@ configurations.configureEach {
// Temporary workaround for https://issuetracker.google.com/174733673
force("org.objenesis:objenesis:2.6")
}
}
}

@ -82,9 +82,9 @@ class NiaAppStateTest {
}
assertEquals(3, state.topLevelDestinations.size)
assertTrue(state.topLevelDestinations[0].destination.contains("for_you"))
assertTrue(state.topLevelDestinations[1].destination.contains("bookmarks"))
assertTrue(state.topLevelDestinations[2].destination.contains("interests"))
assertTrue(state.topLevelDestinations[0].name.contains("for_you", true))
assertTrue(state.topLevelDestinations[1].name.contains("bookmarks", true))
assertTrue(state.topLevelDestinations[2].name.contains("interests", true))
}
@Test

@ -20,15 +20,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
import com.google.samples.apps.nowinandroid.feature.author.navigation.authorGraph
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksGraph
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouGraph
import com.google.samples.apps.nowinandroid.feature.author.navigation.authorScreen
import com.google.samples.apps.nowinandroid.feature.author.navigation.navigateToAuthor
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicGraph
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
/**
* Top-level navigation graph. Navigation is organized as explained at
@ -40,32 +39,27 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicGraph
@Composable
fun NiaNavHost(
navController: NavHostController,
onNavigateToDestination: (NiaNavigationDestination, String) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
startDestination: String = ForYouDestination.route
startDestination: String = forYouNavigationRoute
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
forYouGraph()
bookmarksGraph()
forYouScreen()
bookmarksScreen()
interestsGraph(
navigateToTopic = {
onNavigateToDestination(
TopicDestination, TopicDestination.createNavigationRoute(it)
)
navigateToTopic = { topicId ->
navController.navigateToTopic(topicId)
},
navigateToAuthor = {
onNavigateToDestination(
AuthorDestination, AuthorDestination.createNavigationRoute(it)
)
navigateToAuthor = { authorId ->
navController.navigateToAuthor(authorId)
},
nestedGraphs = {
topicGraph(onBackClick)
authorGraph(onBackClick)
topicScreen(onBackClick)
authorScreen(onBackClick)
}
)
}

@ -17,17 +17,36 @@
package com.google.samples.apps.nowinandroid.navigation
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
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.interests.R as interestsR
/**
* Type for the top level destinations in the application. Each of these destinations
* can contain one or more screens (based on the window size). Navigation from one screen to the
* next within a single destination will be handled directly in composables.
*/
data class TopLevelDestination(
override val route: String,
override val destination: String,
enum class TopLevelDestination(
val selectedIcon: Icon,
val unselectedIcon: Icon,
val iconTextId: Int
) : NiaNavigationDestination
) {
FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you
),
BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved
),
INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interestsR.string.interests
)
}

@ -52,7 +52,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavig
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -69,7 +68,9 @@ fun NiaApp(
NiaTheme {
val background: @Composable (@Composable () -> Unit) -> Unit =
when (appState.currentDestination?.route) {
ForYouDestination.route -> { content -> NiaGradientBackground(content = content) }
TopLevelDestination.FOR_YOU.name -> { content ->
NiaGradientBackground(content = content)
}
else -> { content -> NiaBackground(content = content) }
}
@ -85,7 +86,7 @@ fun NiaApp(
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigate,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination
)
}
@ -103,7 +104,7 @@ fun NiaApp(
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigate,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.safeDrawingPadding()
)
@ -112,7 +113,6 @@ fun NiaApp(
NiaNavHost(
navController = appState.navController,
onBackClick = appState::onBackClick,
onNavigateToDestination = appState::navigate,
modifier = Modifier
.padding(padding)
.consumedWindowInsets(padding)
@ -132,8 +132,7 @@ private fun NiaNavRail(
) {
NiaNavigationRail(modifier = modifier) {
destinations.forEach { destination ->
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationRailItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
@ -168,8 +167,7 @@ private fun NiaBottomBar(
) {
NiaNavigationBar {
destinations.forEach { destination ->
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationBarItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
@ -195,3 +193,8 @@ private fun NiaBottomBar(
}
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false
} ?: false

@ -28,19 +28,16 @@ import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksDestination
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsDestination
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
@Composable
fun rememberNiaAppState(
@ -72,61 +69,35 @@ class NiaAppState(
/**
* Top level destinations to be used in the BottomBar and NavRail
*/
val topLevelDestinations: List<TopLevelDestination> = listOf(
TopLevelDestination(
route = ForYouDestination.route,
destination = ForYouDestination.destination,
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you
),
TopLevelDestination(
route = BookmarksDestination.route,
destination = BookmarksDestination.destination,
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved
),
TopLevelDestination(
route = InterestsDestination.route,
destination = InterestsDestination.destination,
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interestsR.string.interests
)
)
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().asList()
/**
* UI logic for navigating to a particular destination in the app. The NavigationOptions to
* navigate with are based on the type of destination, which could be a top level destination or
* just a regular destination.
* UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you
* navigate to and from it.
*
* Top level destinations have only one copy of the destination of the back stack, and save and
* restore state whenever you navigate to and from it.
* Regular destinations can have multiple copies in the back stack and state isn't saved nor
* restored.
*
* @param destination: The [NiaNavigationDestination] the app needs to navigate to.
* @param route: Optional route to navigate to in case the destination contains arguments.
* @param topLevelDestination: The destination the app needs to navigate to.
*/
fun navigate(destination: NiaNavigationDestination, route: String? = null) {
trace("Navigation: ${destination.route}") {
if (destination is TopLevelDestination) {
navController.navigate(route ?: destination.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
trace("Navigation: ${topLevelDestination.name}") {
val topLevelNavOptions = navOptions {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
} else {
navController.navigate(route ?: destination.route)
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions)
}
}
}

@ -44,7 +44,6 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", project(":core:designsystem"))
add("implementation", project(":core:data"))
add("implementation", project(":core:common"))
add("implementation", project(":core:navigation"))
add("implementation", project(":core:domain"))
add("testImplementation", project(":core:testing"))
@ -68,4 +67,4 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
}
}
}
}
}

@ -13,19 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.navigation"
}
package com.google.samples.apps.nowinandroid.core.decoder
dependencies {
api(libs.androidx.hilt.navigation.compose)
api(libs.androidx.navigation.compose)
}
interface StringDecoder {
fun decodeString(encodedString: String): String
}

@ -0,0 +1,24 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.decoder
import android.net.Uri
import javax.inject.Inject
class UriDecoder @Inject constructor() : StringDecoder {
override fun decodeString(encodedString: String): String = Uri.decode(encodedString)
}

@ -0,0 +1,31 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.decoder.di
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.decoder.UriDecoder
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class StringDecoderModule {
@Binds
abstract fun bindStringDecoder(uriDecoder: UriDecoder): StringDecoder
}

@ -1 +0,0 @@
/build

@ -1,3 +0,0 @@
# :core:navigation module
![Dependency graph](../../docs/images/graphs/dep_graph_core_navigation.png)

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -1,38 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.navigation
/**
* Interface for describing the Now in Android navigation destinations
*/
interface NiaNavigationDestination {
/**
* Defines a specific route this destination belongs to.
* Route is a String that defines the path to your composable.
* You can think of it as an implicit deep link that leads to a specific destination.
* Each destination should have a unique route.
*/
val route: String
/**
* Defines a specific destination ID.
* This is needed when using nested graphs via the navigation DLS, to differentiate a specific
* destination's route from the route of the entire nested graph it belongs to.
*/
val destination: String
}

@ -0,0 +1,24 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.testing.decoder
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import javax.inject.Inject
class FakeStringDecoder @Inject constructor() : StringDecoder {
override fun decodeString(encodedString: String): String = encodedString
}

@ -0,0 +1,35 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.testing.di
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.decoder.di.StringDecoderModule
import com.google.samples.apps.nowinandroid.core.testing.decoder.FakeStringDecoder
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [StringDecoderModule::class],
)
abstract class TestStringDecoderModule {
@Binds
abstract fun bindsStringDecoder(fakeStringDecoder: FakeStringDecoder): StringDecoder
}

@ -224,14 +224,6 @@ Using the above modularization strategy, the Now in Android app has the followin
<code>NewsResource</code>
</td>
</tr>
<tr>
<td><code>core:navigation</code>
</td>
<td>Navigation dependencies and shared navigation classes.
</td>
<td><code>NiaNavigationDestination</code>
</td>
</tr>
</table>

@ -65,7 +65,7 @@ import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun AuthorRoute(
internal fun AuthorRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AuthorViewModel = hiltViewModel(),

@ -21,13 +21,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Author
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.author.navigation.AuthorDestination
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorArgs
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@ -41,17 +42,16 @@ import kotlinx.coroutines.launch
@HiltViewModel
class AuthorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase
) : ViewModel() {
private val authorId: String = checkNotNull(
savedStateHandle[AuthorDestination.authorIdArg]
)
private val authorArgs: AuthorArgs = AuthorArgs(savedStateHandle, stringDecoder)
val authorUiState: StateFlow<AuthorUiState> = authorUiStateStream(
authorId = authorId,
authorId = authorArgs.authorId,
userDataRepository = userDataRepository,
authorsRepository = authorsRepository
)
@ -62,7 +62,7 @@ class AuthorViewModel @Inject constructor(
)
val newsUiState: StateFlow<NewsUiState> =
getSaveableNewsResourcesStream.newsUiStateStream(authorId = authorId)
getSaveableNewsResourcesStream.newsUiStateStream(authorId = authorArgs.authorId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
@ -71,7 +71,7 @@ class AuthorViewModel @Inject constructor(
fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedAuthorId(authorId, followed)
userDataRepository.toggleFollowedAuthorId(authorArgs.authorId, followed)
}
}

@ -17,43 +17,36 @@
package com.google.samples.apps.nowinandroid.feature.author.navigation
import android.net.Uri
import androidx.navigation.NavBackStackEntry
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.feature.author.AuthorRoute
object AuthorDestination : NiaNavigationDestination {
const val authorIdArg = "authorId"
override val route = "author_route/{$authorIdArg}"
override val destination = "author_destination"
@VisibleForTesting
internal const val authorIdArg = "authorId"
/**
* Creates destination route for an authorId that could include special characters
*/
fun createNavigationRoute(authorIdArg: String): String {
val encodedId = Uri.encode(authorIdArg)
return "author_route/$encodedId"
}
internal class AuthorArgs(val authorId: String) {
constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) :
this(stringDecoder.decodeString(checkNotNull(savedStateHandle[authorIdArg])))
}
/**
* Returns the authorId from a [NavBackStackEntry] after an author destination navigation call
*/
fun fromNavArgs(entry: NavBackStackEntry): String {
val encodedId = entry.arguments?.getString(authorIdArg)!!
return Uri.decode(encodedId)
}
fun NavController.navigateToAuthor(authorId: String) {
val encodedString = Uri.encode(authorId)
this.navigate("author_route/$encodedString")
}
fun NavGraphBuilder.authorGraph(
fun NavGraphBuilder.authorScreen(
onBackClick: () -> Unit
) {
composable(
route = AuthorDestination.route,
route = "author_route/{$authorIdArg}",
arguments = listOf(
navArgument(AuthorDestination.authorIdArg) { type = NavType.StringType }
navArgument(authorIdArg) { type = NavType.StringType }
)
) {
AuthorRoute(onBackClick = onBackClick)

@ -22,11 +22,12 @@ import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.testing.decoder.FakeStringDecoder
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
import com.google.samples.apps.nowinandroid.feature.author.navigation.authorIdArg
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@ -63,9 +64,10 @@ class AuthorViewModelTest {
viewModel = AuthorViewModel(
savedStateHandle = SavedStateHandle(
mapOf(
AuthorDestination.authorIdArg to testInputAuthors[0].author.id
authorIdArg to testInputAuthors[0].author.id
)
),
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository,
authorsRepository = authorsRepository,
getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase

@ -54,7 +54,7 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun BookmarksRoute(
internal fun BookmarksRoute(
modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel()
) {

@ -16,18 +16,20 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
object BookmarksDestination : NiaNavigationDestination {
override val route = "bookmarks_route"
override val destination = "bookmarks_destination"
private const val bookmarksRoute = "bookmarks_route"
fun NavController.navigateToBookmarks(navOptions: NavOptions? = null) {
this.navigate(bookmarksRoute, navOptions)
}
fun NavGraphBuilder.bookmarksGraph() {
composable(route = BookmarksDestination.route) {
fun NavGraphBuilder.bookmarksScreen() {
composable(route = bookmarksRoute) {
BookmarksRoute()
}
}

@ -100,7 +100,7 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun ForYouRoute(
internal fun ForYouRoute(
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel()
) {
@ -124,7 +124,7 @@ fun ForYouRoute(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ForYouScreen(
internal fun ForYouScreen(
isOffline: Boolean,
isSyncing: Boolean,
interestsSelectionState: ForYouInterestsSelectionUiState,

@ -16,18 +16,20 @@
package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
object ForYouDestination : NiaNavigationDestination {
override val route = "for_you_route"
override val destination = "for_you_destination"
const val forYouNavigationRoute = "for_you_route"
fun NavController.navigateToForYou(navOptions: NavOptions? = null) {
this.navigate(forYouNavigationRoute, navOptions)
}
fun NavGraphBuilder.forYouGraph() {
composable(route = ForYouDestination.route) {
fun NavGraphBuilder.forYouScreen() {
composable(route = forYouNavigationRoute) {
ForYouRoute()
}
}

@ -45,7 +45,7 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun InterestsRoute(
internal fun InterestsRoute(
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit,
modifier: Modifier = Modifier,
@ -76,7 +76,7 @@ fun InterestsRoute(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InterestsScreen(
internal fun InterestsScreen(
uiState: InterestsUiState,
tabState: InterestsTabState,
followAuthor: (String, Boolean) -> Unit,

@ -16,28 +16,30 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
object InterestsDestination : NiaNavigationDestination {
override val route = "interests_route"
override val destination = "interests_destination"
private const val interestsGraphRoutePattern = "interests_graph"
private const val interestsRoute = "interests_route"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
this.navigate(interestsGraphRoutePattern, navOptions)
}
fun NavGraphBuilder.interestsGraph(
navigateToTopic: (String) -> Unit,
navigateToAuthor: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit
) {
navigation(
route = InterestsDestination.route,
startDestination = InterestsDestination.destination
route = interestsGraphRoutePattern,
startDestination = interestsRoute
) {
composable(route = InterestsDestination.destination) {
composable(route = interestsRoute) {
InterestsRoute(
navigateToTopic = navigateToTopic,
navigateToAuthor = navigateToAuthor,

@ -64,7 +64,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun TopicRoute(
internal fun TopicRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),

@ -21,13 +21,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
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.TopicDestination
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@ -41,16 +42,17 @@ import kotlinx.coroutines.launch
@HiltViewModel
class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
// newsRepository: NewsRepository,
getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase
) : ViewModel() {
private val topicId: String = checkNotNull(savedStateHandle[TopicDestination.topicIdArg])
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder)
val topicUiState: StateFlow<TopicUiState> = topicUiStateStream(
topicId = topicId,
topicId = topicArgs.topicId,
userDataRepository = userDataRepository,
topicsRepository = topicsRepository
)
@ -61,7 +63,7 @@ class TopicViewModel @Inject constructor(
)
val newUiState: StateFlow<NewsUiState> = newsUiStateStream(
topicId = topicId,
topicId = topicArgs.topicId,
userDataRepository = userDataRepository,
getSaveableNewsResourcesStream = getSaveableNewsResourcesStream
)
@ -73,7 +75,7 @@ class TopicViewModel @Inject constructor(
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedTopicId(topicId, followed)
userDataRepository.toggleFollowedTopicId(topicArgs.topicId, followed)
}
}

@ -17,43 +17,36 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation
import android.net.Uri
import androidx.navigation.NavBackStackEntry
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
object TopicDestination : NiaNavigationDestination {
const val topicIdArg = "topicId"
override val route = "topic_route/{$topicIdArg}"
override val destination = "topic_destination"
@VisibleForTesting
internal const val topicIdArg = "topicId"
/**
* Creates destination route for a topicId that could include special characters
*/
fun createNavigationRoute(topicIdArg: String): String {
val encodedId = Uri.encode(topicIdArg)
return "topic_route/$encodedId"
}
internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) :
this(stringDecoder.decodeString(checkNotNull(savedStateHandle[topicIdArg])))
}
/**
* Returns the topicId from a [NavBackStackEntry] after a topic destination navigation call
*/
fun fromNavArgs(entry: NavBackStackEntry): String {
val encodedId = entry.arguments?.getString(topicIdArg)!!
return Uri.decode(encodedId)
}
fun NavController.navigateToTopic(topicId: String) {
val encodedId = Uri.encode(topicId)
this.navigate("topic_route/$encodedId")
}
fun NavGraphBuilder.topicGraph(
fun NavGraphBuilder.topicScreen(
onBackClick: () -> Unit
) {
composable(
route = TopicDestination.route,
route = "topic_route/{$topicIdArg}",
arguments = listOf(
navArgument(TopicDestination.topicIdArg) { type = NavType.StringType }
navArgument(topicIdArg) { type = NavType.StringType }
)
) {
TopicRoute(onBackClick = onBackClick)

@ -22,11 +22,12 @@ import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.decoder.FakeStringDecoder
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
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicIdArg
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@ -61,8 +62,8 @@ class TopicViewModelTest {
@Before
fun setup() {
viewModel = TopicViewModel(
savedStateHandle =
SavedStateHandle(mapOf(TopicDestination.topicIdArg to testInputTopics[0].topic.id)),
savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)),
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase

@ -43,7 +43,6 @@ include(":core:datastore-test")
include(":core:designsystem")
include(":core:domain")
include(":core:model")
include(":core:navigation")
include(":core:network")
include(":core:ui")
include(":core:testing")
@ -54,4 +53,4 @@ include(":feature:bookmarks")
include(":feature:topic")
include(":lint")
include(":sync:work")
include(":sync:sync-test")
include(":sync:sync-test")

Loading…
Cancel
Save