diff --git a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index 9d0dbe3e8..182f2b1fd 100644 --- a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.test.performScrollToNode 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.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.Topic import kotlinx.datetime.Instant import org.junit.Before @@ -56,10 +57,11 @@ class TopicScreenTest { fun niaLoadingWheel_whenScreenIsLoading_showLoading() { composeTestRule.setContent { TopicScreen( - topicState = TopicUiState.Loading, - newsState = NewsUiState.Loading, + topicUiState = TopicUiState.Loading, + newsUiState = NewsUiState.Loading, onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } @@ -73,10 +75,11 @@ class TopicScreenTest { val testTopic = testTopics.first() composeTestRule.setContent { TopicScreen( - topicState = TopicUiState.Success(testTopic), - newsState = NewsUiState.Loading, + topicUiState = TopicUiState.Success(testTopic), + newsUiState = NewsUiState.Loading, onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } @@ -95,10 +98,18 @@ class TopicScreenTest { fun news_whenTopicIsLoading_isNotShown() { composeTestRule.setContent { TopicScreen( - topicState = TopicUiState.Loading, - newsState = NewsUiState.Success(sampleNewsResources), + topicUiState = TopicUiState.Loading, + newsUiState = NewsUiState.Success( + sampleNewsResources.mapIndexed { index, newsResource -> + SaveableNewsResource( + newsResource = newsResource, + isSaved = index % 2 == 0, + ) + } + ), onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } @@ -107,15 +118,24 @@ class TopicScreenTest { .onNodeWithContentDescription(topicLoading) .assertExists() } + @Test fun news_whenSuccessAndTopicIsSuccess_isShown() { val testTopic = testTopics.first() composeTestRule.setContent { TopicScreen( - topicState = TopicUiState.Success(testTopic), - newsState = NewsUiState.Success(sampleNewsResources), + topicUiState = TopicUiState.Success(testTopic), + newsUiState = NewsUiState.Success( + sampleNewsResources.mapIndexed { index, newsResource -> + SaveableNewsResource( + newsResource = newsResource, + isSaved = index % 2 == 0, + ) + } + ), onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index b0a2cee56..942922720 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -52,6 +52,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilte 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.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews @@ -66,24 +67,27 @@ fun TopicRoute( modifier: Modifier = Modifier, viewModel: TopicViewModel = hiltViewModel(), ) { - val uiState: TopicScreenUiState by viewModel.uiState.collectAsStateWithLifecycle() + val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle() + val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle() TopicScreen( - topicState = uiState.topicState, - newsState = uiState.newsState, + topicUiState = topicUiState, + newsUiState = newsUiState, modifier = modifier, onBackClick = onBackClick, onFollowClick = viewModel::followTopicToggle, + onBookmarkChanged = viewModel::bookmarkNews, ) } @VisibleForTesting @Composable internal fun TopicScreen( - topicState: TopicUiState, - newsState: NewsUiState, + topicUiState: TopicUiState, + newsUiState: NewsUiState, onBackClick: () -> Unit, onFollowClick: (Boolean) -> Unit, + onBookmarkChanged: (String, Boolean) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -93,7 +97,7 @@ internal fun TopicScreen( item { Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) } - when (topicState) { + when (topicUiState) { Loading -> item { NiaLoadingWheel( modifier = modifier, @@ -106,14 +110,15 @@ internal fun TopicScreen( TopicToolbar( onBackClick = onBackClick, onFollowClick = onFollowClick, - uiState = topicState.followableTopic, + uiState = topicUiState.followableTopic, ) } TopicBody( - name = topicState.followableTopic.topic.name, - description = topicState.followableTopic.topic.longDescription, - news = newsState, - imageUrl = topicState.followableTopic.topic.imageUrl + name = topicUiState.followableTopic.topic.name, + description = topicUiState.followableTopic.topic.longDescription, + news = newsUiState, + imageUrl = topicUiState.followableTopic.topic.imageUrl, + onBookmarkChanged = onBookmarkChanged ) } } @@ -127,14 +132,15 @@ private fun LazyListScope.TopicBody( name: String, description: String, news: NewsUiState, - imageUrl: String + imageUrl: String, + onBookmarkChanged: (String, Boolean) -> Unit ) { // TODO: Show icon if available item { TopicHeader(name, description, imageUrl) } - TopicCards(news) + TopicCards(news, onBookmarkChanged) } @Composable @@ -161,14 +167,17 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) { } } -private fun LazyListScope.TopicCards(news: NewsUiState) { +private fun LazyListScope.TopicCards( + news: NewsUiState, + onBookmarkChanged: (String, Boolean) -> Unit +) { when (news) { is NewsUiState.Success -> { newsResourceCardItems( items = news.news, - newsResourceMapper = { it }, - isBookmarkedMapper = { /* TODO */ false }, - onToggleBookmark = { /* TODO */ }, + newsResourceMapper = { it.newsResource }, + isBookmarkedMapper = { it.isSaved }, + onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) }, itemModifier = Modifier.padding(24.dp) ) } @@ -188,7 +197,7 @@ private fun TopicBodyPreview() { LazyColumn { TopicBody( "Jetpack Compose", "Lorem ipsum maximum", - NewsUiState.Success(emptyList()), "" + NewsUiState.Success(emptyList()), "", { _, _ -> } ) } } @@ -237,10 +246,18 @@ fun TopicScreenPopulated() { NiaTheme { NiaBackground { TopicScreen( - topicState = TopicUiState.Success(FollowableTopic(previewTopics[0], false)), - newsState = NewsUiState.Success(previewNewsResources), + topicUiState = TopicUiState.Success(FollowableTopic(previewTopics[0], false)), + newsUiState = NewsUiState.Success( + previewNewsResources.mapIndexed { index, newsResource -> + SaveableNewsResource( + newsResource = newsResource, + isSaved = index % 2 == 0, + ) + } + ), onBackClick = {}, - onFollowClick = {} + onFollowClick = {}, + onBookmarkChanged = { _, _ -> }, ) } } @@ -252,10 +269,11 @@ fun TopicScreenLoading() { NiaTheme { NiaBackground { TopicScreen( - topicState = TopicUiState.Loading, - newsState = NewsUiState.Loading, + topicUiState = TopicUiState.Loading, + newsUiState = NewsUiState.Loading, onBackClick = {}, - onFollowClick = {} + onFollowClick = {}, + onBookmarkChanged = { _, _ -> }, ) } } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index d647e4bf7..3877fdb57 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -24,6 +24,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository 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.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.asResult @@ -48,63 +49,126 @@ class TopicViewModel @Inject constructor( private val topicId: String = checkNotNull(savedStateHandle[TopicDestination.topicIdArg]) + val topicUiState: StateFlow = topicUiStateStream( + topicId = topicId, + userDataRepository = userDataRepository, + topicsRepository = topicsRepository + ) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = TopicUiState.Loading + ) + + val newUiState: StateFlow = newsUiStateStream( + topicId = topicId, + userDataRepository = userDataRepository, + newsRepository = newsRepository + ) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = NewsUiState.Loading + ) + + fun followTopicToggle(followed: Boolean) { + viewModelScope.launch { + userDataRepository.toggleFollowedTopicId(topicId, followed) + } + } + + fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) { + viewModelScope.launch { + userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) + } + } +} + +private fun topicUiStateStream( + topicId: String, + userDataRepository: UserDataRepository, + topicsRepository: TopicsRepository, +): Flow { // Observe the followed topics, as they could change over time. - private val followedTopicIdsStream: Flow>> = + val followedTopicIdsStream: Flow> = userDataRepository.userDataStream .map { it.followedTopics } - .asResult() // Observe topic information - private val topic: Flow> = topicsRepository.getTopic(topicId).asResult() - - // Observe the News for this topic - private val newsStream: Flow>> = - newsRepository.getNewsResourcesStream( - filterTopicIds = setOf(element = topicId), - ).asResult() - - val uiState: StateFlow = - combine( - followedTopicIdsStream, - topic, - newsStream - ) { followedTopicsResult, topicResult, newsResult -> - val topic: TopicUiState = - if (topicResult is Result.Success && followedTopicsResult is Result.Success) { - val followed = followedTopicsResult.data.contains(topicId) + val topicStream: Flow = topicsRepository.getTopic( + id = topicId + ) + + return combine( + followedTopicIdsStream, + 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 = topicResult.data, + topic = topic, isFollowed = followed ) ) - } else if ( - topicResult is Result.Loading || followedTopicsResult is Result.Loading - ) { + } + is Result.Loading -> { TopicUiState.Loading - } else { + } + is Result.Error -> { TopicUiState.Error } - - val news: NewsUiState = when (newsResult) { - is Result.Success -> NewsUiState.Success(newsResult.data) - is Result.Loading -> NewsUiState.Loading - is Result.Error -> NewsUiState.Error } - - TopicScreenUiState(topic, news) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = TopicScreenUiState(TopicUiState.Loading, NewsUiState.Loading) - ) +} - fun followTopicToggle(followed: Boolean) { - viewModelScope.launch { - userDataRepository.toggleFollowedTopicId(topicId, followed) +private fun newsUiStateStream( + topicId: String, + newsRepository: NewsRepository, + userDataRepository: UserDataRepository, +): Flow { + // Observe news + val newsStream: Flow> = newsRepository.getNewsResourcesStream( + filterAuthorIds = emptySet(), + filterTopicIds = setOf(element = topicId), + ) + + // Observe bookmarks + val bookmarkStream: Flow> = userDataRepository.userDataStream + .map { it.bookmarkedNewsResources } + + return combine( + newsStream, + bookmarkStream, + ::Pair + ) + .asResult() + .map { newsToBookmarksResult -> + when (newsToBookmarksResult) { + is Result.Success -> { + val (news, bookmarks) = newsToBookmarksResult.data + NewsUiState.Success( + news.map { newsResource -> + SaveableNewsResource( + newsResource, + isSaved = bookmarks.contains(newsResource.id) + ) + } + ) + } + is Result.Loading -> { + NewsUiState.Loading + } + is Result.Error -> { + NewsUiState.Error + } + } } - } } sealed interface TopicUiState { @@ -114,12 +178,7 @@ sealed interface TopicUiState { } sealed interface NewsUiState { - data class Success(val news: List) : NewsUiState + data class Success(val news: List) : NewsUiState object Error : NewsUiState object Loading : NewsUiState } - -data class TopicScreenUiState( - val topicState: TopicUiState, - val newsState: NewsUiState -) diff --git a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index 684ce2323..5b9457f0a 100644 --- a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -64,15 +65,15 @@ class TopicViewModelTest { } @Test - fun uiStateAuthor_whenSuccess_matchesTopicFromRepository() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + 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.uiState.value - assertTrue(item.topicState is TopicUiState.Success) + val item = viewModel.topicUiState.value + assertTrue(item is TopicUiState.Success) - val successTopicState = item.topicState as TopicUiState.Success + val successTopicState = item as TopicUiState.Success val topicFromRepository = topicsRepository.getTopic( testInputTopics[0].topic.id ).first() @@ -84,20 +85,20 @@ class TopicViewModelTest { @Test fun uiStateNews_whenInitialized_thenShowLoading() = runTest { - assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState) + assertEquals(NewsUiState.Loading, viewModel.newUiState.value) } @Test fun uiStateTopic_whenInitialized_thenShowLoading() = runTest { - assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState) + assertEquals(TopicUiState.Loading, viewModel.topicUiState.value) } @Test fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) - assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState) + assertEquals(TopicUiState.Loading, viewModel.topicUiState.value) collectJob.cancel() } @@ -105,13 +106,15 @@ class TopicViewModelTest { @Test fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } topicsRepository.sendTopics(testInputTopics.map { it.topic }) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) - val item = viewModel.uiState.value - assertTrue(item.topicState is TopicUiState.Success) - assertTrue(item.newsState is NewsUiState.Loading) + val topicUiState = viewModel.topicUiState.value + val newsUiState = viewModel.newUiState.value + + assertTrue(topicUiState is TopicUiState.Success) + assertTrue(newsUiState is NewsUiState.Loading) collectJob.cancel() } @@ -119,21 +122,28 @@ class TopicViewModelTest { @Test fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } - + 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 item = viewModel.uiState.value - assertTrue(item.topicState is TopicUiState.Success) - assertTrue(item.newsState is NewsUiState.Success) + val topicUiState = viewModel.topicUiState.value + val newsUiState = viewModel.newUiState.value + + assertTrue(topicUiState is TopicUiState.Success) + assertTrue(newsUiState is NewsUiState.Success) collectJob.cancel() } @Test fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } topicsRepository.sendTopics(testInputTopics.map { it.topic }) // Set which topic IDs are followed, not including 0. @@ -143,7 +153,7 @@ class TopicViewModelTest { assertEquals( TopicUiState.Success(followableTopic = testOutputTopics[0]), - viewModel.uiState.value.topicState + viewModel.topicUiState.value ) collectJob.cancel()