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:foryou"))
implementation(project(":feature:bookmarks"))
implementation(project(":feature:topic"))
implementation(project(":feature:search"))
implementation(project(":feature:settings"))

@ -249,22 +249,4 @@ class NavigationTest {
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
currentDestination = state.currentDestination?.route
currentDestination = state.currentBackStackEntry?.destination?.route
// Navigate to destination b once
LaunchedEffect(Unit) {

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

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

@ -16,14 +16,15 @@
package com.google.samples.apps.nowinandroid.ui
import android.os.Bundle
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
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.navigateToForYou
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.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
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.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
@ -85,17 +86,24 @@ class NiaAppState(
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
) {
val currentDestination: NavDestination?
val currentBackStackEntry: NavBackStackEntry?
@Composable get() = navController
.currentBackStackEntryAsState().value?.destination
.currentBackStackEntryAsState().value
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) {
forYouNavigationRoute -> FOR_YOU
bookmarksRoute -> BOOKMARKS
interestsRoute -> INTERESTS
else -> null
@Composable get() {
val route: String? = currentBackStackEntry?.destination?.route
val arguments: Bundle? = currentBackStackEntry?.arguments
return when {
route == forYouNavigationRoute -> FOR_YOU
route == bookmarksRoute -> BOOKMARKS
route == interestsRoute &&
(arguments?.getString(topicIdArg) == null || shouldShowTwoPane) -> INTERESTS
else -> null
}
}
val shouldShowGradientBackground: Boolean
@Composable get() = currentBackStackEntry?.destination?.route == forYouNavigationRoute
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
@ -103,6 +111,9 @@ class NiaAppState(
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
val shouldShowTwoPane: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
@ -159,14 +170,10 @@ class NiaAppState(
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(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
import androidx.activity.ComponentActivity
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
@ -74,7 +75,10 @@ class InterestsScreenTest {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = followableTopicTestData),
uiState = InterestsUiState.Interests(
topics = followableTopicTestData,
selectedTopicId = null,
),
)
}
@ -107,6 +111,7 @@ class InterestsScreenTest {
@Composable
private fun InterestsScreen(uiState: InterestsUiState) {
InterestsScreen(
listState = rememberLazyListState(),
uiState = uiState,
followTopic = { _, _ -> },
onTopicClick = {},

@ -17,23 +17,21 @@
package com.google.samples.apps.nowinandroid.feature.interests
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
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.NiaIconToggleButton
@ -43,6 +41,7 @@ import com.google.samples.apps.nowinandroid.feature.interests.R.string
@Composable
fun InterestsItem(
isSelected: Boolean,
name: String,
following: Boolean,
topicImageUrl: String,
@ -51,63 +50,50 @@ fun InterestsItem(
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
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))
Spacer(modifier = Modifier.width(24.dp))
InterestContent(name, description)
}
NiaIconToggleButton(
checked = following,
onCheckedChange = onFollowButtonClick,
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = stringResource(
id = string.card_follow_button_content_desc,
),
)
},
checkedIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = stringResource(
id = string.card_unfollow_button_content_desc,
),
)
},
)
}
}
@Composable
private fun InterestContent(name: String, description: String, modifier: Modifier = Modifier) {
Column(modifier) {
Text(
text = name,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(
vertical = if (description.isEmpty()) 0.dp else 4.dp,
),
)
if (description.isNotEmpty()) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
},
headlineText = {
Text(text = name)
},
supportingText = {
Text(text = description)
},
trailingContent = {
NiaIconToggleButton(
checked = following,
onCheckedChange = onFollowButtonClick,
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = stringResource(
id = string.card_follow_button_content_desc,
),
)
},
checkedIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = stringResource(
id = string.card_unfollow_button_content_desc,
),
)
},
)
}
}
},
modifier = modifier
.semantics(mergeDescendants = true) { /* no-op */ }
.selectable(selected = isSelected, onClick = onClick),
colors = ListItemDefaults.colors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.surface
} else {
Color.Transparent
},
),
)
}
@Composable
@ -135,6 +121,7 @@ private fun InterestsCardPreview() {
NiaTheme {
Surface {
InterestsItem(
isSelected = false,
name = "Compose",
description = "Description",
following = false,
@ -152,6 +139,7 @@ private fun InterestsCardLongNamePreview() {
NiaTheme {
Surface {
InterestsItem(
isSelected = false,
name = "This is a very very very very long name",
description = "Description",
following = true,
@ -169,6 +157,7 @@ private fun InterestsCardLongDescriptionPreview() {
NiaTheme {
Surface {
InterestsItem(
isSelected = false,
name = "Compose",
description = "This is a very very very very very very very " +
"very very very long description",
@ -187,6 +176,7 @@ private fun InterestsCardWithEmptyDescriptionPreview() {
NiaTheme {
Surface {
InterestsItem(
isSelected = false,
name = "Compose",
description = "",
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
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.NiaLoadingWheel
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.FollowableTopicPreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
@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,
)
}
import com.google.samples.apps.nowinandroid.feature.interests.R.string
@Composable
internal fun InterestsScreen(
uiState: InterestsUiState,
followTopic: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
listState: LazyGridState = rememberLazyGridState(),
modifier: Modifier = Modifier,
) {
Column(
@ -65,15 +50,18 @@ internal fun InterestsScreen(
InterestsUiState.Loading ->
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = R.string.loading),
contentDesc = stringResource(id = string.loading),
)
is InterestsUiState.Interests ->
TopicsTabContent(
selectedTopicId = uiState.selectedTopicId,
listState = listState,
topics = uiState.topics,
onTopicClick = onTopicClick,
onFollowButtonClick = followTopic,
modifier = modifier,
)
is InterestsUiState.Empty -> InterestsEmptyScreen()
}
}
@ -96,6 +84,7 @@ fun InterestsScreenPopulated(
InterestsScreen(
uiState = InterestsUiState.Interests(
topics = followableTopics,
selectedTopicId = null,
),
followTopic = { _, _ -> },
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
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.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
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.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -34,30 +47,98 @@ import javax.inject.Inject
class InterestsViewModel @Inject constructor(
val userDataRepository: UserDataRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
userNewsResourceRepository: UserNewsResourceRepository,
topicsRepository: TopicsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
val uiState: StateFlow<InterestsUiState> =
getFollowableTopics(sortBy = TopicSortField.NAME).map(
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
private val topicId: StateFlow<String?> =
savedStateHandle.getStateFlow(topicIdArg, null)
val interestUiState: StateFlow<InterestsUiState> = combine(
getFollowableTopics(sortBy = TopicSortField.NAME),
topicId,
) { topics, selectedTopicId ->
InterestsUiState.Interests(
topics = topics,
selectedTopicId = selectedTopicId,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
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 {
userDataRepository.toggleFollowedTopicId(followedTopicId, isFollowed)
}
}
fun bookmarkNews(newsResourceId: String, isBookmarked: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, isBookmarked)
}
}
fun followTopic(followedTopicId: String, followed: Boolean) {
fun newsViewed(newsResourceId: String) {
viewModelScope.launch {
userDataRepository.toggleFollowedTopicId(followedTopicId, followed)
userDataRepository.setNewsResourceViewed(newsResourceId, true)
}
}
}
sealed interface InterestsUiState {
object Loading : InterestsUiState
private fun topicUiState(
topicId: String?,
userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository,
topicsRepository: TopicsRepository,
): Flow<TopicUiState?> {
if (topicId == null) {
return flowOf(null)
}
// 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)
data class Interests(
val topics: List<FollowableTopic>,
) : InterestsUiState
val newsResourcesStream: Flow<List<UserNewsResource>> = userNewsResourceRepository.observeAll(
NewsResourceQuery(filterTopicIds = setOf(element = topicId)),
)
object Empty : InterestsUiState
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");
* you may not use this file except in compliance with the License.
@ -14,16 +14,12 @@
* limitations under the License.
*/
plugins {
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
}
package com.google.samples.apps.nowinandroid.feature.interests
android {
namespace = "com.google.samples.apps.nowinandroid.feature.topic"
}
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
dependencies {
implementation(libs.kotlinx.datetime)
}
sealed interface NewsUiState {
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
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.ui.Modifier
import androidx.compose.ui.platform.testTag
@ -31,30 +35,36 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@Composable
fun TopicsTabContent(
selectedTopicId: String?,
topics: List<FollowableTopic>,
onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
listState: LazyGridState = rememberLazyGridState(),
withBottomSpacer: Boolean = true,
) {
LazyColumn(
LazyVerticalGrid(
columns = GridCells.Adaptive(300.dp),
state = listState,
modifier = modifier
.padding(horizontal = 24.dp)
.selectableGroup()
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
) {
topics.forEach { followableTopic ->
items(
items = topics,
key = { item -> item.topic.id },
) { followableTopic ->
val topicId = followableTopic.topic.id
item(key = topicId) {
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
InterestsItem(
isSelected = selectedTopicId == topicId,
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
if (withBottomSpacer) {

@ -14,11 +14,11 @@
* 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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
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.NiaBackground
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.UserNewsResource
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.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
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
internal fun TopicScreen(
topicUiState: TopicUiState,
newsUiState: NewsUiState,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
onFollowClick: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val state = rememberLazyListState()
val state = rememberLazyGridState()
TrackScrollJank(scrollableState = state, stateName = "topic:screen")
LazyColumn(
LazyVerticalGrid(
columns = GridCells.Adaptive(300.dp),
state = state,
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
.selectableGroup()
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (topicUiState) {
TopicUiState.Loading -> item {
NiaLoadingWheel(
@ -113,19 +93,26 @@ internal fun TopicScreen(
)
}
TopicUiState.Error -> TODO()
is TopicUiState.Success -> {
Error -> {
item {
Text(text = stringResource(id = string.topic_error))
}
}
is Success -> {
item(span = { GridItemSpan(maxLineSpan) }) {
TopicToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
onFollowClick = { isChecked ->
onFollowClick(topicUiState.followableTopic.topic.id, isChecked)
},
uiState = topicUiState.followableTopic,
)
}
TopicBody(
topicBody(
name = topicUiState.followableTopic.topic.name,
description = topicUiState.followableTopic.topic.longDescription,
news = newsUiState,
news = topicUiState.newsResources,
imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged,
onNewsResourceViewed = onNewsResourceViewed,
@ -133,27 +120,34 @@ internal fun TopicScreen(
)
}
}
item {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
private fun LazyListScope.TopicBody(
private fun LazyGridScope.topicBody(
name: String,
description: String,
news: NewsUiState,
news: List<UserNewsResource>,
imageUrl: String,
onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit,
) {
// TODO: Show icon if available
item {
item(span = { GridItemSpan(maxLineSpan) }) {
TopicHeader(name, description, imageUrl)
}
userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick)
newsFeed(
feedState = NewsFeedUiState.Success(
news,
),
onNewsResourceViewed = onNewsResourceViewed,
onNewsResourcesCheckedChanged = onBookmarkChanged,
onTopicClick = onTopicClick,
)
}
@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
@Composable
private fun TopicBodyPreview() {
NiaTheme {
LazyColumn {
TopicBody(
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
topicBody(
name = "Jetpack Compose",
description = "Lorem ipsum maximum",
news = NewsUiState.Success(emptyList()),
news = emptyList(),
imageUrl = "",
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
@ -272,10 +238,12 @@ fun TopicScreenPopulated(
NiaTheme {
NiaBackground {
TopicScreen(
topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]),
newsUiState = NewsUiState.Success(userNewsResources),
topicUiState = Success(
followableTopic = userNewsResources[0].followableTopics[0],
newsResources = userNewsResources,
),
onBackClick = {},
onFollowClick = {},
onFollowClick = { _, _ -> },
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {},
@ -290,10 +258,9 @@ fun TopicScreenLoading() {
NiaTheme {
NiaBackground {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Loading,
topicUiState = Loading,
onBackClick = {},
onFollowClick = {},
onFollowClick = { _, _ -> },
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
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
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
private const val interestsGraphRoutePattern = "interests_graph"
const val interestsRoute = "interests_route"
const val topicIdArg = "topicId"
const val interestsRoute = "interests_route?$topicIdArg={$topicIdArg}"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
this.navigate(interestsGraphRoutePattern, navOptions)
fun NavController.navigateToInterests(
selectedTopicId: String? = null,
navOptions: NavOptions? = null,
) {
if (selectedTopicId != null) {
navigate("interests_route?$topicIdArg=$selectedTopicId", navOptions)
} else {
navigate("interests_route", navOptions)
}
}
fun NavGraphBuilder.interestsGraph(
listState: LazyGridState,
shouldShowTwoPane: Boolean,
onTopicClick: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit,
onBackClick: () -> Unit,
) {
navigation(
route = interestsGraphRoutePattern,
startDestination = interestsRoute,
composable(
route = interestsRoute,
arguments = listOf(
navArgument(topicIdArg) { nullable = true },
),
) {
composable(route = interestsRoute) {
InterestsRoute(onTopicClick)
}
nestedGraphs()
InterestsRoute(listState, shouldShowTwoPane, onTopicClick, onBackClick)
}
}

@ -20,4 +20,9 @@
<string name="empty_header">"No available data"</string>
<string name="card_follow_button_content_desc">Follow 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>

@ -16,22 +16,31 @@
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.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState
import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
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
@ -43,39 +52,48 @@ class InterestsViewModelTest {
val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository,
userDataRepository = userDataRepository,
)
private val topicsRepository = TestTopicsRepository()
private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
topicsRepository = topicsRepository,
userDataRepository = userDataRepository,
)
private val selectedTopidId: String = testInputTopics[0].topic.id
private lateinit var viewModel: InterestsViewModel
@Before
fun setup() {
viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(mapOf(topicIdArg to selectedTopidId)),
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository,
)
}
@Test
fun uiState_whenInitialized_thenShowLoading() = runTest {
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
assertEquals(InterestsUiState.Loading, viewModel.interestUiState.value)
}
@Test
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.interestUiState.collect() }
userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
assertEquals(InterestsUiState.Loading, viewModel.interestUiState.value)
collectJob.cancel()
}
@Test
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
topicsRepository.sendTopics(testInputTopics.map { it.topic })
@ -83,7 +101,7 @@ class InterestsViewModelTest {
assertEquals(
false,
(viewModel.uiState.value as InterestsUiState.Interests)
(viewModel.interestUiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed,
)
@ -93,8 +111,8 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testOutputTopics),
viewModel.uiState.value,
InterestsUiState.Interests(topics = testOutputTopics, selectedTopicId = selectedTopidId),
viewModel.interestUiState.value,
)
collectJob.cancel()
@ -102,7 +120,7 @@ class InterestsViewModelTest {
@Test
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
@ -113,7 +131,7 @@ class InterestsViewModelTest {
assertEquals(
true,
(viewModel.uiState.value as InterestsUiState.Interests)
(viewModel.interestUiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed,
)
@ -123,90 +141,44 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testInputTopics),
viewModel.uiState.value,
InterestsUiState.Interests(topics = testInputTopics, selectedTopicId = selectedTopidId),
viewModel.interestUiState.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,
),
)
@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)
}
}
}
@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()
}
}

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

@ -324,6 +324,7 @@ private fun SearchResultBody(
},
) {
InterestsItem(
isSelected = false,
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
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:interests")
include(":feature:bookmarks")
include(":feature:topic")
include(":feature:search")
include(":feature:settings")
include(":lint")

Loading…
Cancel
Save