Implement list/detail view for interests and topics

feature/interests-list-detail
Miłosz Moczkowski 2 years ago
parent 9cd390c56a
commit 6c6538ff83

@ -83,7 +83,6 @@ dependencies {
implementation(project(":feature:interests")) implementation(project(":feature:interests"))
implementation(project(":feature:foryou")) implementation(project(":feature:foryou"))
implementation(project(":feature:bookmarks")) implementation(project(":feature:bookmarks"))
implementation(project(":feature:topic"))
implementation(project(":feature:search")) implementation(project(":feature:search"))
implementation(project(":feature:settings")) implementation(project(":feature:settings"))

@ -249,22 +249,4 @@ class NavigationTest {
onNodeWithText(forYou).assertExists() onNodeWithText(forYou).assertExists()
} }
} }
@Test
fun navigationBar_multipleBackStackInterests() {
composeTestRule.apply {
onNodeWithText(interests).performClick()
// TODO: Grab string from fake data
onNodeWithText("Android Studio & Tools").performClick()
// Switch tab
onNodeWithText(forYou).performClick()
// Come back to Interests
onNodeWithText(interests).performClick()
// Verify we're not in the list of interests
onNodeWithText("Android Auto").assertDoesNotExist() // TODO: Grab string from fake data
}
}
} }

@ -82,7 +82,7 @@ class NiaAppStateTest {
} }
// Update currentDestination whenever it changes // Update currentDestination whenever it changes
currentDestination = state.currentDestination?.route currentDestination = state.currentBackStackEntry?.destination?.route
// Navigate to destination b once // Navigate to destination b once
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.navigation package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -23,10 +24,8 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmar
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute 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.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState import com.google.samples.apps.nowinandroid.ui.NiaAppState
/** /**
@ -44,32 +43,28 @@ fun NiaNavHost(
startDestination: String = forYouNavigationRoute, startDestination: String = forYouNavigationRoute,
) { ) {
val navController = appState.navController val navController = appState.navController
val interestsScrollState = rememberLazyGridState()
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
modifier = modifier, modifier = modifier,
) { ) {
// TODO: handle topic clicks from each top level destination // TODO: handle topic clicks from each top level destination
forYouScreen(onTopicClick = {}) forYouScreen(onTopicClick = navController::navigateToInterests)
bookmarksScreen( bookmarksScreen(
onTopicClick = navController::navigateToTopic, onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar, onShowSnackbar = onShowSnackbar,
) )
searchScreen( searchScreen(
onBackClick = navController::popBackStack, onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, onInterestsClick = navController::navigateToInterests,
onTopicClick = navController::navigateToTopic, onTopicClick = navController::navigateToInterests,
) )
interestsGraph( interestsGraph(
onTopicClick = { topicId -> listState = interestsScrollState,
navController.navigateToTopic(topicId) shouldShowTwoPane = appState.shouldShowTwoPane,
}, onTopicClick = navController::navigateToInterests,
nestedGraphs = { onBackClick = navController::navigateToInterests,
topicScreen(
onBackClick = navController::popBackStack,
onTopicClick = {},
)
},
) )
} }
} }

@ -74,6 +74,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -95,15 +96,13 @@ fun NiaApp(
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
), ),
) { ) {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
var showSettingsDialog by rememberSaveable { var showSettingsDialog by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
NiaBackground { NiaBackground {
NiaGradientBackground( NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) { gradientColors = if (appState.shouldShowGradientBackground) {
LocalGradientColors.current LocalGradientColors.current
} else { } else {
GradientColors() GradientColors()
@ -146,7 +145,7 @@ fun NiaApp(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations, destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination, onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination, currentDestination = appState.currentBackStackEntry?.destination,
modifier = Modifier.testTag("NiaBottomBar"), modifier = Modifier.testTag("NiaBottomBar"),
) )
} }
@ -168,7 +167,7 @@ fun NiaApp(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations, destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination, onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination, currentDestination = appState.currentBackStackEntry?.destination,
modifier = Modifier modifier = Modifier
.testTag("NiaNavRail") .testTag("NiaNavRail")
.safeDrawingPadding(), .safeDrawingPadding(),
@ -193,7 +192,7 @@ fun NiaApp(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
onActionClick = { showSettingsDialog = true }, onActionClick = { showSettingsDialog = true },
onNavigationClick = { appState.navigateToSearch() }, onNavigationClick = { appState.navController.navigateToSearch() },
) )
} }

@ -16,14 +16,15 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import android.os.Bundle
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@ -38,8 +39,8 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigat
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination 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.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
@ -85,17 +86,24 @@ class NiaAppState(
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
) { ) {
val currentDestination: NavDestination? val currentBackStackEntry: NavBackStackEntry?
@Composable get() = navController @Composable get() = navController
.currentBackStackEntryAsState().value?.destination .currentBackStackEntryAsState().value
val currentTopLevelDestination: TopLevelDestination? val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) { @Composable get() {
forYouNavigationRoute -> FOR_YOU val route: String? = currentBackStackEntry?.destination?.route
bookmarksRoute -> BOOKMARKS val arguments: Bundle? = currentBackStackEntry?.arguments
interestsRoute -> INTERESTS return when {
route == forYouNavigationRoute -> FOR_YOU
route == bookmarksRoute -> BOOKMARKS
route == interestsRoute &&
(arguments?.getString(topicIdArg) == null || shouldShowTwoPane) -> INTERESTS
else -> null else -> null
} }
}
val shouldShowGradientBackground: Boolean
@Composable get() = currentBackStackEntry?.destination?.route == forYouNavigationRoute
val shouldShowBottomBar: Boolean val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
@ -103,6 +111,9 @@ class NiaAppState(
val shouldShowNavRail: Boolean val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar get() = !shouldShowBottomBar
val shouldShowTwoPane: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded
val isOffline = networkMonitor.isOnline val isOffline = networkMonitor.isOnline
.map(Boolean::not) .map(Boolean::not)
.stateIn( .stateIn(
@ -159,14 +170,10 @@ class NiaAppState(
when (topLevelDestination) { when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions) FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions) BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions) INTERESTS -> navController.navigateToInterests(navOptions = topLevelNavOptions)
} }
} }
} }
fun navigateToSearch() {
navController.navigateToSearch()
}
} }
/** /**

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
@ -74,7 +75,10 @@ class InterestsScreenTest {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() { fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent { composeTestRule.setContent {
InterestsScreen( InterestsScreen(
uiState = InterestsUiState.Interests(topics = followableTopicTestData), uiState = InterestsUiState.Interests(
topics = followableTopicTestData,
selectedTopicId = null,
),
) )
} }
@ -107,6 +111,7 @@ class InterestsScreenTest {
@Composable @Composable
private fun InterestsScreen(uiState: InterestsUiState) { private fun InterestsScreen(uiState: InterestsUiState) {
InterestsScreen( InterestsScreen(
listState = rememberLazyListState(),
uiState = uiState, uiState = uiState,
followTopic = { _, _ -> }, followTopic = { _, _ -> },
onTopicClick = {}, onTopicClick = {},

@ -17,23 +17,21 @@
package com.google.samples.apps.nowinandroid.feature.interests package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
@ -43,6 +41,7 @@ import com.google.samples.apps.nowinandroid.feature.interests.R.string
@Composable @Composable
fun InterestsItem( fun InterestsItem(
isSelected: Boolean,
name: String, name: String,
following: Boolean, following: Boolean,
topicImageUrl: String, topicImageUrl: String,
@ -51,23 +50,18 @@ fun InterestsItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier, iconModifier: Modifier = Modifier,
description: String = "", description: String = "",
itemSeparation: Dp = 16.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.clickable { onClick() }
.padding(vertical = itemSeparation),
) { ) {
ListItem(
leadingContent = {
InterestsIcon(topicImageUrl, iconModifier.size(64.dp)) InterestsIcon(topicImageUrl, iconModifier.size(64.dp))
Spacer(modifier = Modifier.width(24.dp)) },
InterestContent(name, description) headlineText = {
} Text(text = name)
},
supportingText = {
Text(text = description)
},
trailingContent = {
NiaIconToggleButton( NiaIconToggleButton(
checked = following, checked = following,
onCheckedChange = onFollowButtonClick, onCheckedChange = onFollowButtonClick,
@ -88,26 +82,18 @@ fun InterestsItem(
) )
}, },
) )
} },
} modifier = modifier
.semantics(mergeDescendants = true) { /* no-op */ }
@Composable .selectable(selected = isSelected, onClick = onClick),
private fun InterestContent(name: String, description: String, modifier: Modifier = Modifier) { colors = ListItemDefaults.colors(
Column(modifier) { containerColor = if (isSelected) {
Text( MaterialTheme.colorScheme.surface
text = name, } else {
style = MaterialTheme.typography.headlineSmall, Color.Transparent
modifier = Modifier.padding( },
vertical = if (description.isEmpty()) 0.dp else 4.dp,
), ),
) )
if (description.isNotEmpty()) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
)
}
}
} }
@Composable @Composable
@ -135,6 +121,7 @@ private fun InterestsCardPreview() {
NiaTheme { NiaTheme {
Surface { Surface {
InterestsItem( InterestsItem(
isSelected = false,
name = "Compose", name = "Compose",
description = "Description", description = "Description",
following = false, following = false,
@ -152,6 +139,7 @@ private fun InterestsCardLongNamePreview() {
NiaTheme { NiaTheme {
Surface { Surface {
InterestsItem( InterestsItem(
isSelected = false,
name = "This is a very very very very long name", name = "This is a very very very very long name",
description = "Description", description = "Description",
following = true, following = true,
@ -169,6 +157,7 @@ private fun InterestsCardLongDescriptionPreview() {
NiaTheme { NiaTheme {
Surface { Surface {
InterestsItem( InterestsItem(
isSelected = false,
name = "Compose", name = "Compose",
description = "This is a very very very very very very very " + description = "This is a very very very very very very very " +
"very very very long description", "very very very long description",
@ -187,6 +176,7 @@ private fun InterestsCardWithEmptyDescriptionPreview() {
NiaTheme { NiaTheme {
Surface { Surface {
InterestsItem( InterestsItem(
isSelected = false,
name = "Compose", name = "Compose",
description = "", description = "",
following = true, following = true,

@ -0,0 +1,97 @@
/*
* Copyright 2023 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.feature.interests
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
internal fun InterestsRoute(
listState: LazyGridState,
shouldShowTwoPane: Boolean,
onTopicClick: (String) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel(),
) {
val interestUiState by viewModel.interestUiState.collectAsStateWithLifecycle()
val topicUiState by viewModel.topicUiState.collectAsStateWithLifecycle()
Row(modifier = modifier.fillMaxSize()) {
if (shouldShowTwoPane || topicUiState == null) {
Box(
modifier = Modifier
.fillMaxHeight()
.then(
if (topicUiState != null) {
Modifier.widthIn(min = 350.dp)
} else {
Modifier.weight(1f)
},
),
) {
InterestsScreen(
uiState = interestUiState,
listState = listState,
followTopic = viewModel::followTopic,
onTopicClick = onTopicClick,
modifier = Modifier.matchParentSize(),
)
}
}
AnimatedVisibility(
visible = topicUiState != null,
enter = slideInHorizontally(initialOffsetX = { it / 2 }),
exit = slideOutHorizontally(targetOffsetX = { it / 2 }),
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.run {
if (!shouldShowTwoPane) {
safeDrawingPadding()
} else {
this
}
},
) {
topicUiState?.let { state ->
TopicScreen(
topicUiState = state,
onBackClick = onBackClick,
onFollowClick = viewModel::followTopic,
onTopicClick = onTopicClick,
onBookmarkChanged = viewModel::bookmarkNews,
onNewsResourceViewed = viewModel::newsViewed,
)
}
}
}
}

@ -17,15 +17,14 @@
package com.google.samples.apps.nowinandroid.feature.interests package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
@ -33,28 +32,14 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.feature.interests.R.string
@Composable
internal fun InterestsRoute(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
InterestsScreen(
uiState = uiState,
followTopic = viewModel::followTopic,
onTopicClick = onTopicClick,
modifier = modifier,
)
}
@Composable @Composable
internal fun InterestsScreen( internal fun InterestsScreen(
uiState: InterestsUiState, uiState: InterestsUiState,
followTopic: (String, Boolean) -> Unit, followTopic: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
listState: LazyGridState = rememberLazyGridState(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -65,15 +50,18 @@ internal fun InterestsScreen(
InterestsUiState.Loading -> InterestsUiState.Loading ->
NiaLoadingWheel( NiaLoadingWheel(
modifier = modifier, modifier = modifier,
contentDesc = stringResource(id = R.string.loading), contentDesc = stringResource(id = string.loading),
) )
is InterestsUiState.Interests -> is InterestsUiState.Interests ->
TopicsTabContent( TopicsTabContent(
selectedTopicId = uiState.selectedTopicId,
listState = listState,
topics = uiState.topics, topics = uiState.topics,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
onFollowButtonClick = followTopic, onFollowButtonClick = followTopic,
modifier = modifier,
) )
is InterestsUiState.Empty -> InterestsEmptyScreen() is InterestsUiState.Empty -> InterestsEmptyScreen()
} }
} }
@ -96,6 +84,7 @@ fun InterestsScreenPopulated(
InterestsScreen( InterestsScreen(
uiState = InterestsUiState.Interests( uiState = InterestsUiState.Interests(
topics = followableTopics, topics = followableTopics,
selectedTopicId = null,
), ),
followTopic = { _, _ -> }, followTopic = { _, _ -> },
onTopicClick = {}, onTopicClick = {},

@ -0,0 +1,30 @@
/*
* Copyright 2023 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.feature.interests
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
sealed interface InterestsUiState {
object Loading : InterestsUiState
data class Interests(
val topics: List<FollowableTopic>,
val selectedTopicId: String?,
) : InterestsUiState
object Empty : InterestsUiState
}

@ -16,16 +16,29 @@
package com.google.samples.apps.nowinandroid.feature.interests package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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 import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -34,30 +47,98 @@ import javax.inject.Inject
class InterestsViewModel @Inject constructor( class InterestsViewModel @Inject constructor(
val userDataRepository: UserDataRepository, val userDataRepository: UserDataRepository,
getFollowableTopics: GetFollowableTopicsUseCase, getFollowableTopics: GetFollowableTopicsUseCase,
userNewsResourceRepository: UserNewsResourceRepository,
topicsRepository: TopicsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<InterestsUiState> = private val topicId: StateFlow<String?> =
getFollowableTopics(sortBy = TopicSortField.NAME).map( savedStateHandle.getStateFlow(topicIdArg, null)
InterestsUiState::Interests,
).stateIn( val interestUiState: StateFlow<InterestsUiState> = combine(
getFollowableTopics(sortBy = TopicSortField.NAME),
topicId,
) { topics, selectedTopicId ->
InterestsUiState.Interests(
topics = topics,
selectedTopicId = selectedTopicId,
)
}.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading, initialValue = InterestsUiState.Loading,
) )
fun followTopic(followedTopicId: String, followed: Boolean) { val topicUiState: StateFlow<TopicUiState?> = topicId.flatMapLatest { topicId ->
topicUiState(
topicId,
userDataRepository,
userNewsResourceRepository,
topicsRepository,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
fun followTopic(followedTopicId: String, isFollowed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.toggleFollowedTopicId(followedTopicId, followed) userDataRepository.toggleFollowedTopicId(followedTopicId, isFollowed)
} }
} }
fun bookmarkNews(newsResourceId: String, isBookmarked: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, isBookmarked)
}
} }
sealed interface InterestsUiState { fun newsViewed(newsResourceId: String) {
object Loading : InterestsUiState viewModelScope.launch {
userDataRepository.setNewsResourceViewed(newsResourceId, true)
}
}
}
private fun topicUiState(
topicId: String?,
userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository,
topicsRepository: TopicsRepository,
): Flow<TopicUiState?> {
if (topicId == null) {
return flowOf(null)
}
data class Interests( // Observe the followed topics, as they could change over time.
val topics: List<FollowableTopic>, val followedTopicIds: Flow<Set<String>> =
) : InterestsUiState userDataRepository.userData
.map { it.followedTopics }
object Empty : InterestsUiState // Observe topic information
val topicStream: Flow<Topic> = topicsRepository.getTopic(id = topicId)
val newsResourcesStream: Flow<List<UserNewsResource>> = userNewsResourceRepository.observeAll(
NewsResourceQuery(filterTopicIds = setOf(element = topicId)),
)
return combine<_, _, _, TopicUiState>(
followedTopicIds,
topicStream,
newsResourcesStream,
) { followedTopics, topic, newsResources ->
val followed = followedTopics.contains(topicId)
TopicUiState.Success(
followableTopic = FollowableTopic(
topic = topic,
isFollowed = followed,
),
newsResources = newsResources,
)
}.onStart {
emit(TopicUiState.Loading)
}.catch {
emit(TopicUiState.Error)
}
} }

@ -1,5 +1,5 @@
/* /*
* Copyright 2022 The Android Open Source Project * Copyright 2023 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,16 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { package com.google.samples.apps.nowinandroid.feature.interests
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
}
android { import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
namespace = "com.google.samples.apps.nowinandroid.feature.topic"
}
dependencies { sealed interface NewsUiState {
implementation(libs.kotlinx.datetime) data class Success(val news: List<UserNewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
} }

@ -19,10 +19,14 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@ -31,22 +35,29 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@Composable @Composable
fun TopicsTabContent( fun TopicsTabContent(
selectedTopicId: String?,
topics: List<FollowableTopic>, topics: List<FollowableTopic>,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyGridState = rememberLazyGridState(),
withBottomSpacer: Boolean = true, withBottomSpacer: Boolean = true,
) { ) {
LazyColumn( LazyVerticalGrid(
columns = GridCells.Adaptive(300.dp),
state = listState,
modifier = modifier modifier = modifier
.padding(horizontal = 24.dp) .selectableGroup()
.testTag("interests:topics"), .testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp), contentPadding = PaddingValues(vertical = 16.dp),
) { ) {
topics.forEach { followableTopic -> items(
items = topics,
key = { item -> item.topic.id },
) { followableTopic ->
val topicId = followableTopic.topic.id val topicId = followableTopic.topic.id
item(key = topicId) {
InterestsItem( InterestsItem(
isSelected = selectedTopicId == topicId,
name = followableTopic.topic.name, name = followableTopic.topic.name,
following = followableTopic.isFollowed, following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription, description = followableTopic.topic.shortDescription,
@ -55,7 +66,6 @@ fun TopicsTabContent(
onFollowButtonClick = { onFollowButtonClick(topicId, it) }, onFollowButtonClick = { onFollowButtonClick(topicId, it) },
) )
} }
}
if (withBottomSpacer) { if (withBottomSpacer) {
item { item {

@ -14,11 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.interests
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@ -27,24 +27,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
@ -54,57 +54,37 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.topic.R.string import com.google.samples.apps.nowinandroid.feature.interests.R.string
import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState.Error
import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState.Loading
import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState.Success
@Composable
internal fun TopicRoute(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),
) {
val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle()
val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle()
TrackScreenViewEvent(screenName = "Topic: ${viewModel.topicId}")
TopicScreen(
topicUiState = topicUiState,
newsUiState = newsUiState,
modifier = modifier,
onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews,
onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
onTopicClick = onTopicClick,
)
}
@VisibleForTesting
@Composable @Composable
internal fun TopicScreen( internal fun TopicScreen(
topicUiState: TopicUiState, topicUiState: TopicUiState,
newsUiState: NewsUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit, onFollowClick: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit, onNewsResourceViewed: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val state = rememberLazyListState() val state = rememberLazyGridState()
TrackScrollJank(scrollableState = state, stateName = "topic:screen") TrackScrollJank(scrollableState = state, stateName = "topic:screen")
LazyColumn(
LazyVerticalGrid(
columns = GridCells.Adaptive(300.dp),
state = state, state = state,
modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier
.selectableGroup()
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
) { ) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (topicUiState) { when (topicUiState) {
TopicUiState.Loading -> item { TopicUiState.Loading -> item {
NiaLoadingWheel( NiaLoadingWheel(
@ -113,19 +93,26 @@ internal fun TopicScreen(
) )
} }
TopicUiState.Error -> TODO() Error -> {
is TopicUiState.Success -> {
item { item {
Text(text = stringResource(id = string.topic_error))
}
}
is Success -> {
item(span = { GridItemSpan(maxLineSpan) }) {
TopicToolbar( TopicToolbar(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = onFollowClick, onFollowClick = { isChecked ->
onFollowClick(topicUiState.followableTopic.topic.id, isChecked)
},
uiState = topicUiState.followableTopic, uiState = topicUiState.followableTopic,
) )
} }
TopicBody( topicBody(
name = topicUiState.followableTopic.topic.name, name = topicUiState.followableTopic.topic.name,
description = topicUiState.followableTopic.topic.longDescription, description = topicUiState.followableTopic.topic.longDescription,
news = newsUiState, news = topicUiState.newsResources,
imageUrl = topicUiState.followableTopic.topic.imageUrl, imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged, onBookmarkChanged = onBookmarkChanged,
onNewsResourceViewed = onNewsResourceViewed, onNewsResourceViewed = onNewsResourceViewed,
@ -133,27 +120,34 @@ internal fun TopicScreen(
) )
} }
} }
item {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
} }
} }
} }
private fun LazyListScope.TopicBody( private fun LazyGridScope.topicBody(
name: String, name: String,
description: String, description: String,
news: NewsUiState, news: List<UserNewsResource>,
imageUrl: String, imageUrl: String,
onBookmarkChanged: (String, Boolean) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit, onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
// TODO: Show icon if available // TODO: Show icon if available
item { item(span = { GridItemSpan(maxLineSpan) }) {
TopicHeader(name, description, imageUrl) TopicHeader(name, description, imageUrl)
} }
newsFeed(
userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick) feedState = NewsFeedUiState.Success(
news,
),
onNewsResourceViewed = onNewsResourceViewed,
onNewsResourcesCheckedChanged = onBookmarkChanged,
onTopicClick = onTopicClick,
)
} }
@Composable @Composable
@ -180,43 +174,15 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) {
} }
} }
// TODO: Could/should this be replaced with [LazyGridScope.newsFeed]?
private fun LazyListScope.userNewsResourceCards(
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit,
) {
when (news) {
is NewsUiState.Success -> {
userNewsResourceCardItems(
items = news.news,
onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) },
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
itemModifier = Modifier.padding(24.dp),
)
}
is NewsUiState.Loading -> item {
NiaLoadingWheel(contentDesc = "Loading news") // TODO
}
else -> item {
Text("Error") // TODO
}
}
}
@Preview @Preview
@Composable @Composable
private fun TopicBodyPreview() { private fun TopicBodyPreview() {
NiaTheme { NiaTheme {
LazyColumn { LazyVerticalGrid(columns = GridCells.Fixed(2)) {
TopicBody( topicBody(
name = "Jetpack Compose", name = "Jetpack Compose",
description = "Lorem ipsum maximum", description = "Lorem ipsum maximum",
news = NewsUiState.Success(emptyList()), news = emptyList(),
imageUrl = "", imageUrl = "",
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
@ -272,10 +238,12 @@ fun TopicScreenPopulated(
NiaTheme { NiaTheme {
NiaBackground { NiaBackground {
TopicScreen( TopicScreen(
topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]), topicUiState = Success(
newsUiState = NewsUiState.Success(userNewsResources), followableTopic = userNewsResources[0].followableTopics[0],
newsResources = userNewsResources,
),
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = { _, _ -> },
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
@ -290,10 +258,9 @@ fun TopicScreenLoading() {
NiaTheme { NiaTheme {
NiaBackground { NiaBackground {
TopicScreen( TopicScreen(
topicUiState = TopicUiState.Loading, topicUiState = Loading,
newsUiState = NewsUiState.Loading,
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = { _, _ -> },
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},

@ -0,0 +1,30 @@
/*
* Copyright 2023 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.feature.interests
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
sealed interface TopicUiState {
data class Success(
val followableTopic: FollowableTopic,
val newsResources: List<UserNewsResource>,
) : TopicUiState
object Error : TopicUiState
object Loading : TopicUiState
}

@ -16,31 +16,40 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
private const val interestsGraphRoutePattern = "interests_graph" const val topicIdArg = "topicId"
const val interestsRoute = "interests_route" const val interestsRoute = "interests_route?$topicIdArg={$topicIdArg}"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) { fun NavController.navigateToInterests(
this.navigate(interestsGraphRoutePattern, navOptions) selectedTopicId: String? = null,
navOptions: NavOptions? = null,
) {
if (selectedTopicId != null) {
navigate("interests_route?$topicIdArg=$selectedTopicId", navOptions)
} else {
navigate("interests_route", navOptions)
}
} }
fun NavGraphBuilder.interestsGraph( fun NavGraphBuilder.interestsGraph(
listState: LazyGridState,
shouldShowTwoPane: Boolean,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit, onBackClick: () -> Unit,
) { ) {
navigation( composable(
route = interestsGraphRoutePattern, route = interestsRoute,
startDestination = interestsRoute, arguments = listOf(
navArgument(topicIdArg) { nullable = true },
),
) { ) {
composable(route = interestsRoute) { InterestsRoute(listState, shouldShowTwoPane, onTopicClick, onBackClick)
InterestsRoute(onTopicClick)
}
nestedGraphs()
} }
} }

@ -20,4 +20,9 @@
<string name="empty_header">"No available data"</string> <string name="empty_header">"No available data"</string>
<string name="card_follow_button_content_desc">Follow interest</string> <string name="card_follow_button_content_desc">Follow interest</string>
<string name="card_unfollow_button_content_desc">Unfollow interest</string> <string name="card_unfollow_button_content_desc">Unfollow interest</string>
<string name="top_app_bar_title">Interests</string>
<string name="top_app_bar_action_menu">Menu</string>
<string name="top_app_bar_action_search">Search</string>
<string name="topic_loading">Loading topic</string>
<string name="topic_error">Error loading topic</string>
</resources> </resources>

@ -16,22 +16,31 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState
import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see
@ -43,39 +52,48 @@ class InterestsViewModelTest {
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository,
userDataRepository = userDataRepository,
)
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )
private val selectedTopidId: String = testInputTopics[0].topic.id
private lateinit var viewModel: InterestsViewModel private lateinit var viewModel: InterestsViewModel
@Before @Before
fun setup() { fun setup() {
viewModel = InterestsViewModel( viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(mapOf(topicIdArg to selectedTopidId)),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@Test @Test
fun uiState_whenInitialized_thenShowLoading() = runTest { fun uiState_whenInitialized_thenShowLoading() = runTest {
assertEquals(InterestsUiState.Loading, viewModel.uiState.value) assertEquals(InterestsUiState.Loading, viewModel.interestUiState.value)
} }
@Test @Test
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest { fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.interestUiState.collect() }
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(InterestsUiState.Loading, viewModel.uiState.value) assertEquals(InterestsUiState.Loading, viewModel.interestUiState.value)
collectJob.cancel() collectJob.cancel()
} }
@Test @Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest { fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.interestUiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
@ -83,7 +101,7 @@ class InterestsViewModelTest {
assertEquals( assertEquals(
false, false,
(viewModel.uiState.value as InterestsUiState.Interests) (viewModel.interestUiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed, .topics.first { it.topic.id == toggleTopicId }.isFollowed,
) )
@ -93,8 +111,8 @@ class InterestsViewModelTest {
) )
assertEquals( assertEquals(
InterestsUiState.Interests(topics = testOutputTopics), InterestsUiState.Interests(topics = testOutputTopics, selectedTopicId = selectedTopidId),
viewModel.uiState.value, viewModel.interestUiState.value,
) )
collectJob.cancel() collectJob.cancel()
@ -102,7 +120,7 @@ class InterestsViewModelTest {
@Test @Test
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest { fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.interestUiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
@ -113,7 +131,7 @@ class InterestsViewModelTest {
assertEquals( assertEquals(
true, true,
(viewModel.uiState.value as InterestsUiState.Interests) (viewModel.interestUiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed, .topics.first { it.topic.id == toggleTopicId }.isFollowed,
) )
@ -123,90 +141,44 @@ class InterestsViewModelTest {
) )
assertEquals( assertEquals(
InterestsUiState.Interests(topics = testInputTopics), InterestsUiState.Interests(topics = testInputTopics, selectedTopicId = selectedTopidId),
viewModel.uiState.value, viewModel.interestUiState.value,
) )
collectJob.cancel() collectJob.cancel()
} }
@Test
fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest {
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(followableTopicTestData[1].topic.id))
newsRepository.sendNewsResources(newsResourcesTestData)
runBlocking(UnconfinedTestDispatcher()) {
viewModel.topicUiState.test {
assertEquals(null, awaitItem())
assertIs<TopicUiState.Loading>(awaitItem())
assertIs<TopicUiState.Success>(awaitItem())
val item = viewModel.topicUiState.value
assertIs<TopicUiState.Success>(item)
val topicFromRepository = topicsRepository.getTopic(
testInputTopics[0].topic.id,
).first()
assertEquals(topicFromRepository, item.followableTopic.topic)
}
}
} }
private const val TOPIC_1_NAME = "Android Studio" @Test
private const val TOPIC_2_NAME = "Build" fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest {
private const val TOPIC_3_NAME = "Compose" val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
private const val TOPIC_SHORT_DESC = "At vero eos et accusamus."
private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus."
private const val TOPIC_URL = "URL"
private const val TOPIC_IMAGE_URL = "Image URL"
private val testInputTopics = listOf(
FollowableTopic(
Topic(
id = "0",
name = TOPIC_1_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "1",
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
FollowableTopic(
Topic(
id = "2",
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
)
private val testOutputTopics = listOf( userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
FollowableTopic( assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)
Topic(
id = "0", collectJob.cancel()
name = TOPIC_1_NAME, }
shortDescription = TOPIC_SHORT_DESC, }
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "1",
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "2",
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
)

@ -0,0 +1,100 @@
/*
* Copyright 2023 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.interests
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
private const val TOPIC_1_NAME = "Android Studio"
private const val TOPIC_2_NAME = "Build"
private const val TOPIC_3_NAME = "Compose"
private const val TOPIC_SHORT_DESC = "At vero eos et accusamus."
private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus."
private const val TOPIC_URL = "URL"
private const val TOPIC_IMAGE_URL = "Image URL"
internal val testInputTopics = listOf(
FollowableTopic(
Topic(
id = "0",
name = TOPIC_1_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "1",
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
FollowableTopic(
Topic(
id = "2",
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
)
internal val testOutputTopics = listOf(
FollowableTopic(
Topic(
id = "0",
name = TOPIC_1_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "1",
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "2",
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
)

@ -17,14 +17,15 @@
package com.google.samples.apps.nowinandroid.feature.search package com.google.samples.apps.nowinandroid.feature.search
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToIndex
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
@ -139,22 +140,18 @@ class SearchScreenTest {
composeTestRule composeTestRule
.onNodeWithText(topicsString) .onNodeWithText(topicsString)
.assertIsDisplayed() .assertIsDisplayed()
composeTestRule
.onNodeWithText(followableTopicTestData[0].topic.name) val scrollableNode = composeTestRule
.assertIsDisplayed() .onAllNodes(hasScrollToNodeAction())
composeTestRule .onFirst()
.onNodeWithText(followableTopicTestData[1].topic.name)
.assertIsDisplayed() followableTopicTestData.forEachIndexed { index, followableTopic ->
composeTestRule scrollableNode.performScrollToIndex(index)
.onNodeWithText(followableTopicTestData[2].topic.name)
.assertIsDisplayed()
composeTestRule composeTestRule
.onAllNodesWithContentDescription(followButtonContentDesc) .onNodeWithText(followableTopic.topic.name)
.assertCountEquals(2) .assertIsDisplayed()
composeTestRule }
.onAllNodesWithContentDescription(unfollowButtonContentDesc)
.assertCountEquals(1)
} }
@Test @Test

@ -324,6 +324,7 @@ private fun SearchResultBody(
}, },
) { ) {
InterestsItem( InterestsItem(
isSelected = false,
name = followableTopic.topic.name, name = followableTopic.topic.name,
following = followableTopic.isFollowed, following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription, description = followableTopic.topic.shortDescription,

@ -1 +0,0 @@
/build

@ -1,3 +0,0 @@
# :feature:topic module
![Dependency graph](../../docs/images/graphs/dep_graph_feature_topic.png)

@ -1,140 +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.feature.topic
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* UI test for checking the correct behaviour of the Topic screen;
* Verifies that, when a specific UiState is set, the corresponding
* composables and details are shown
*/
class TopicScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var topicLoading: String
@Before
fun setup() {
composeTestRule.activity.apply {
topicLoading = getString(R.string.topic_loading)
}
}
@Test
fun niaLoadingWheel_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
)
}
composeTestRule
.onNodeWithContentDescription(topicLoading)
.assertExists()
}
@Test
fun topicTitle_whenTopicIsSuccess_isShown() {
val testTopic = followableTopicTestData.first()
composeTestRule.setContent {
TopicScreen(
topicUiState = TopicUiState.Success(testTopic),
newsUiState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
)
}
// Name is shown
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertExists()
// Description is shown
composeTestRule
.onNodeWithText(testTopic.topic.longDescription)
.assertExists()
}
@Test
fun news_whenTopicIsLoading_isNotShown() {
composeTestRule.setContent {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Success(userNewsResourcesTestData),
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
)
}
// Loading indicator shown
composeTestRule
.onNodeWithContentDescription(topicLoading)
.assertExists()
}
@Test
fun news_whenSuccessAndTopicIsSuccess_isShown() {
val testTopic = followableTopicTestData.first()
composeTestRule.setContent {
TopicScreen(
topicUiState = TopicUiState.Success(testTopic),
newsUiState = NewsUiState.Success(
userNewsResourcesTestData,
),
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
)
}
// Scroll to first news title if available
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(hasText(userNewsResourcesTestData.first().title))
}
}

@ -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,190 +0,0 @@
/*
* Copyright 2021 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.feature.topic
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder)
val topicId = topicArgs.topicId
val topicUiState: StateFlow<TopicUiState> = topicUiState(
topicId = topicArgs.topicId,
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TopicUiState.Loading,
)
val newUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicArgs.topicId,
userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository,
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsUiState.Loading,
)
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedTopicId(topicArgs.topicId, followed)
}
}
fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked)
}
}
fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
viewModelScope.launch {
userDataRepository.setNewsResourceViewed(newsResourceId, viewed)
}
}
}
private fun topicUiState(
topicId: String,
userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
): Flow<TopicUiState> {
// Observe the followed topics, as they could change over time.
val followedTopicIds: Flow<Set<String>> =
userDataRepository.userData
.map { it.followedTopics }
// Observe topic information
val topicStream: Flow<Topic> = topicsRepository.getTopic(
id = topicId,
)
return combine(
followedTopicIds,
topicStream,
::Pair,
)
.asResult()
.map { followedTopicToTopicResult ->
when (followedTopicToTopicResult) {
is Result.Success -> {
val (followedTopics, topic) = followedTopicToTopicResult.data
val followed = followedTopics.contains(topicId)
TopicUiState.Success(
followableTopic = FollowableTopic(
topic = topic,
isFollowed = followed,
),
)
}
is Result.Loading -> {
TopicUiState.Loading
}
is Result.Error -> {
TopicUiState.Error
}
}
}
}
private fun newsUiState(
topicId: String,
userNewsResourceRepository: UserNewsResourceRepository,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> {
// Observe news
val newsStream: Flow<List<UserNewsResource>> = userNewsResourceRepository.observeAll(
NewsResourceQuery(filterTopicIds = setOf(element = topicId)),
)
// Observe bookmarks
val bookmark: Flow<Set<String>> = userDataRepository.userData
.map { it.bookmarkedNewsResources }
return combine(
newsStream,
bookmark,
::Pair,
)
.asResult()
.map { newsToBookmarksResult ->
when (newsToBookmarksResult) {
is Result.Success -> {
val news = newsToBookmarksResult.data.first
NewsUiState.Success(news)
}
is Result.Loading -> {
NewsUiState.Loading
}
is Result.Error -> {
NewsUiState.Error
}
}
}
}
sealed interface TopicUiState {
data class Success(val followableTopic: FollowableTopic) : TopicUiState
object Error : TopicUiState
object Loading : TopicUiState
}
sealed interface NewsUiState {
data class Success(val news: List<UserNewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}

@ -1,57 +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.feature.topic.navigation
import android.net.Uri
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.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
@VisibleForTesting
internal const val topicIdArg = "topicId"
internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) :
this(stringDecoder.decodeString(checkNotNull(savedStateHandle[topicIdArg])))
}
fun NavController.navigateToTopic(topicId: String) {
val encodedId = Uri.encode(topicId)
this.navigate("topic_route/$encodedId") {
launchSingleTop = true
}
}
fun NavGraphBuilder.topicScreen(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
composable(
route = "topic_route/{$topicIdArg}",
arguments = listOf(
navArgument(topicIdArg) { type = NavType.StringType },
),
) {
TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick)
}
}

@ -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.
-->
<resources>
<string name="topic_loading">Loading topic</string>
</resources>

@ -1,275 +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.feature.topic
import androidx.lifecycle.SavedStateHandle
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
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.topicIdArg
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
import org.junit.Test
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
*/
class TopicViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository,
userDataRepository = userDataRepository,
)
private lateinit var viewModel: TopicViewModel
@Before
fun setup() {
viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)),
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository,
)
}
@Test
fun topicId_matchesTopicIdFromSavedStateHandle() =
assertEquals(testInputTopics[0].topic.id, viewModel.topicId)
@Test
fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = viewModel.topicUiState.value
assertIs<TopicUiState.Success>(item)
val topicFromRepository = topicsRepository.getTopic(
testInputTopics[0].topic.id,
).first()
assertEquals(topicFromRepository, item.followableTopic.topic)
collectJob.cancel()
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
assertEquals(NewsUiState.Loading, viewModel.newUiState.value)
}
@Test
fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {
assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =
runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val topicUiState = viewModel.topicUiState.value
val newsUiState = viewModel.newUiState.value
assertIs<TopicUiState.Success>(topicUiState)
assertIs<NewsUiState.Loading>(newsUiState)
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
val collectJob = launch(UnconfinedTestDispatcher()) {
combine(
viewModel.topicUiState,
viewModel.newUiState,
::Pair,
).collect()
}
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
newsRepository.sendNewsResources(sampleNewsResources)
val topicUiState = viewModel.topicUiState.value
val newsUiState = viewModel.newUiState.value
assertIs<TopicUiState.Success>(topicUiState)
assertIs<NewsUiState.Success>(newsUiState)
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
// Set which topic IDs are followed, not including 0.
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
viewModel.followTopicToggle(true)
assertEquals(
TopicUiState.Success(followableTopic = testOutputTopics[0]),
viewModel.topicUiState.value,
)
collectJob.cancel()
}
}
private const val TOPIC_1_NAME = "Android Studio"
private const val TOPIC_2_NAME = "Build"
private const val TOPIC_3_NAME = "Compose"
private const val TOPIC_SHORT_DESC = "At vero eos et accusamus."
private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus."
private const val TOPIC_URL = "URL"
private const val TOPIC_IMAGE_URL = "Image URL"
private val testInputTopics = listOf(
FollowableTopic(
Topic(
id = "0",
name = TOPIC_1_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "1",
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
FollowableTopic(
Topic(
id = "2",
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
)
private val testOutputTopics = listOf(
FollowableTopic(
Topic(
id = "0",
name = TOPIC_1_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "1",
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true,
),
FollowableTopic(
Topic(
id = "2",
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false,
),
)
private val sampleNewsResources = listOf(
NewsResource(
id = "1",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
),
),
)

@ -52,7 +52,6 @@ include(":core:notifications")
include(":feature:foryou") include(":feature:foryou")
include(":feature:interests") include(":feature:interests")
include(":feature:bookmarks") include(":feature:bookmarks")
include(":feature:topic")
include(":feature:search") include(":feature:search")
include(":feature:settings") include(":feature:settings")
include(":lint") include(":lint")

Loading…
Cancel
Save