From 44d1e76f9b16394f9ee819512f215c7df8e42fd7 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Wed, 27 Jul 2022 12:44:17 -0400 Subject: [PATCH] Enable bookmarks on authors page Change-Id: I3ef1dd35472dd63718a9ab5dd10d9b4f67d7b9c4 --- .../feature/author/AuthorScreenTest.kt | 44 +++-- .../feature/author/AuthorScreen.kt | 60 ++++--- .../feature/author/AuthorViewModel.kt | 152 ++++++++++++------ .../feature/author/AuthorViewModelTest.kt | 81 +++++++--- 4 files changed, 238 insertions(+), 99 deletions(-) diff --git a/feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt b/feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt index 02e950de0..070226123 100644 --- a/feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt +++ b/feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt @@ -24,6 +24,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor 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 kotlinx.datetime.Instant import org.junit.Before import org.junit.Rule @@ -52,10 +53,11 @@ class AuthorScreenTest { fun niaLoadingWheel_whenScreenIsLoading_showLoading() { composeTestRule.setContent { AuthorScreen( - authorState = AuthorUiState.Loading, - newsState = NewsUiState.Loading, + authorUiState = AuthorUiState.Loading, + newsUiState = NewsUiState.Loading, onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } @@ -69,10 +71,11 @@ class AuthorScreenTest { val testAuthor = testAuthors.first() composeTestRule.setContent { AuthorScreen( - authorState = AuthorUiState.Success(testAuthor), - newsState = NewsUiState.Loading, + authorUiState = AuthorUiState.Success(testAuthor), + newsUiState = NewsUiState.Loading, onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } @@ -91,10 +94,18 @@ class AuthorScreenTest { fun news_whenAuthorIsLoading_isNotShown() { composeTestRule.setContent { AuthorScreen( - authorState = AuthorUiState.Loading, - newsState = NewsUiState.Success(sampleNewsResources), + authorUiState = AuthorUiState.Loading, + newsUiState = NewsUiState.Success( + sampleNewsResources.mapIndexed { index, newsResource -> + SaveableNewsResource( + newsResource = newsResource, + isSaved = index % 2 == 0, + ) + } + ), onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } @@ -103,15 +114,24 @@ class AuthorScreenTest { .onNodeWithContentDescription(authorLoading) .assertExists() } + @Test fun news_whenSuccessAndAuthorIsSuccess_isShown() { val testAuthor = testAuthors.first() composeTestRule.setContent { AuthorScreen( - authorState = AuthorUiState.Success(testAuthor), - newsState = NewsUiState.Success(sampleNewsResources), + authorUiState = AuthorUiState.Success(testAuthor), + newsUiState = NewsUiState.Success( + sampleNewsResources.mapIndexed { index, newsResource -> + SaveableNewsResource( + newsResource = newsResource, + isSaved = index % 2 == 0, + ) + } + ), onBackClick = { }, - onFollowClick = { } + onFollowClick = { }, + onBookmarkChanged = { _, _ -> }, ) } diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt index 5d5addf09..b1f45c9eb 100644 --- a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt +++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt @@ -56,6 +56,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadi import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor +import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems @@ -67,24 +68,27 @@ fun AuthorRoute( modifier: Modifier = Modifier, viewModel: AuthorViewModel = hiltViewModel(), ) { - val uiState: AuthorScreenUiState by viewModel.uiState.collectAsStateWithLifecycle() + val authorUiState: AuthorUiState by viewModel.authorUiState.collectAsStateWithLifecycle() + val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle() AuthorScreen( - authorState = uiState.authorState, - newsState = uiState.newsState, + authorUiState = authorUiState, + newsUiState = newsUiState, modifier = modifier, onBackClick = onBackClick, onFollowClick = viewModel::followAuthorToggle, + onBookmarkChanged = viewModel::bookmarkNews, ) } @VisibleForTesting @Composable internal fun AuthorScreen( - authorState: AuthorUiState, - newsState: NewsUiState, + authorUiState: AuthorUiState, + newsUiState: NewsUiState, onBackClick: () -> Unit, onFollowClick: (Boolean) -> Unit, + onBookmarkChanged: (String, Boolean) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -94,7 +98,7 @@ internal fun AuthorScreen( item { Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) } - when (authorState) { + when (authorUiState) { AuthorUiState.Loading -> { item { NiaLoadingWheel( @@ -111,12 +115,13 @@ internal fun AuthorScreen( AuthorToolbar( onBackClick = onBackClick, onFollowClick = onFollowClick, - uiState = authorState.followableAuthor, + uiState = authorUiState.followableAuthor, ) } authorBody( - author = authorState.followableAuthor.author, - news = newsState + author = authorUiState.followableAuthor.author, + news = newsUiState, + onBookmarkChanged = onBookmarkChanged, ) } } @@ -128,13 +133,14 @@ internal fun AuthorScreen( private fun LazyListScope.authorBody( author: Author, - news: NewsUiState + news: NewsUiState, + onBookmarkChanged: (String, Boolean) -> Unit ) { item { AuthorHeader(author) } - authorCards(news) + authorCards(news, onBookmarkChanged) } @Composable @@ -163,14 +169,17 @@ private fun AuthorHeader(author: Author) { } } -private fun LazyListScope.authorCards(news: NewsUiState) { +private fun LazyListScope.authorCards( + 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) ) } @@ -227,10 +236,18 @@ fun AuthorScreenPopulated() { NiaTheme { NiaBackground { AuthorScreen( - authorState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)), - newsState = NewsUiState.Success(previewNewsResources), + authorUiState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)), + newsUiState = NewsUiState.Success( + previewNewsResources.mapIndexed { index, newsResource -> + SaveableNewsResource( + newsResource = newsResource, + isSaved = index % 2 == 0, + ) + } + ), onBackClick = {}, - onFollowClick = {} + onFollowClick = {}, + onBookmarkChanged = { _, _ -> }, ) } } @@ -245,10 +262,11 @@ fun AuthorScreenLoading() { NiaTheme { NiaBackground { AuthorScreen( - authorState = AuthorUiState.Loading, - newsState = NewsUiState.Loading, + authorUiState = AuthorUiState.Loading, + newsUiState = NewsUiState.Loading, onBackClick = {}, - onFollowClick = {} + onFollowClick = {}, + onBookmarkChanged = { _, _ -> }, ) } } diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt index 3401c0529..889c3a326 100644 --- a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt +++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt @@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor 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.result.Result import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination @@ -50,66 +51,126 @@ class AuthorViewModel @Inject constructor( savedStateHandle[AuthorDestination.authorIdArg] ) + val authorUiState: StateFlow = authorUiStateStream( + authorId = authorId, + userDataRepository = userDataRepository, + authorsRepository = authorsRepository + ) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AuthorUiState.Loading + ) + + val newUiState: StateFlow = newsUiStateStream( + authorId = authorId, + userDataRepository = userDataRepository, + newsRepository = newsRepository + ) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = NewsUiState.Loading + ) + + fun followAuthorToggle(followed: Boolean) { + viewModelScope.launch { + userDataRepository.toggleFollowedAuthorId(authorId, followed) + } + } + + fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) { + viewModelScope.launch { + userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) + } + } +} + +private fun authorUiStateStream( + authorId: String, + userDataRepository: UserDataRepository, + authorsRepository: AuthorsRepository, +): Flow { // Observe the followed authors, as they could change over time. - private val followedAuthorIdsStream: Flow>> = + val followedAuthorIdsStream: Flow> = userDataRepository.userDataStream .map { it.followedAuthors } - .asResult() // Observe author information - private val author: Flow> = authorsRepository.getAuthorStream( + val authorStream: Flow = authorsRepository.getAuthorStream( id = authorId - ).asResult() - - // Observe the News for this author - private val newsStream: Flow>> = - newsRepository.getNewsResourcesStream( - filterAuthorIds = setOf(element = authorId), - filterTopicIds = emptySet() - ).asResult() - - val uiState: StateFlow = - combine( - followedAuthorIdsStream, - author, - newsStream - ) { followedAuthorsResult, authorResult, newsResult -> - val author: AuthorUiState = - if (authorResult is Result.Success && followedAuthorsResult is Result.Success) { - val followed = followedAuthorsResult.data.contains(authorId) + ) + + return combine( + followedAuthorIdsStream, + authorStream, + ::Pair + ) + .asResult() + .map { followedAuthorToAuthorResult -> + when (followedAuthorToAuthorResult) { + is Result.Success -> { + val (followedAuthors, author) = followedAuthorToAuthorResult.data + val followed = followedAuthors.contains(authorId) AuthorUiState.Success( followableAuthor = FollowableAuthor( - author = authorResult.data, + author = author, isFollowed = followed ) ) - } else if ( - authorResult is Result.Loading || followedAuthorsResult is Result.Loading - ) { + } + is Result.Loading -> { AuthorUiState.Loading - } else { + } + is Result.Error -> { AuthorUiState.Error } - - val news: NewsUiState = when (newsResult) { - is Result.Success -> NewsUiState.Success(newsResult.data) - is Result.Loading -> NewsUiState.Loading - is Result.Error -> NewsUiState.Error } - - AuthorScreenUiState(author, news) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = AuthorScreenUiState(AuthorUiState.Loading, NewsUiState.Loading) - ) +} - fun followAuthorToggle(followed: Boolean) { - viewModelScope.launch { - userDataRepository.toggleFollowedAuthorId(authorId, followed) +private fun newsUiStateStream( + authorId: String, + newsRepository: NewsRepository, + userDataRepository: UserDataRepository, +): Flow { + // Observe news + val newsStream: Flow> = newsRepository.getNewsResourcesStream( + filterAuthorIds = setOf(element = authorId), + filterTopicIds = emptySet() + ) + + // 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 AuthorUiState { @@ -119,12 +180,7 @@ sealed interface AuthorUiState { } 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 AuthorScreenUiState( - val authorState: AuthorUiState, - val newsState: NewsUiState -) diff --git a/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt b/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt index 0e1fc48d2..c5a803b7d 100644 --- a/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt +++ b/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.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.author.navigation.AuthorDestination import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -68,16 +69,16 @@ class AuthorViewModelTest { @Test fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() } // To make sure AuthorUiState is success authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author)) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) - val item = viewModel.uiState.value - assertTrue(item.authorState is AuthorUiState.Success) + val item = viewModel.authorUiState.value + assertTrue(item is AuthorUiState.Success) - val successAuthorUiState = item.authorState as AuthorUiState.Success + val successAuthorUiState = item as AuthorUiState.Success val authorFromRepository = authorsRepository.getAuthorStream( id = testInputAuthors[0].author.id ).first() @@ -90,20 +91,20 @@ class AuthorViewModelTest { @Test fun uiStateNews_whenInitialized_thenShowLoading() = runTest { - assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState) + assertEquals(NewsUiState.Loading, viewModel.newUiState.value) } @Test fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest { - assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState) + assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value) } @Test fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() } userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) - assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState) + assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value) collectJob.cancel() } @@ -111,13 +112,21 @@ class AuthorViewModelTest { @Test fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { + combine( + viewModel.authorUiState, + viewModel.newUiState, + ::Pair + ).collect() + } authorsRepository.sendAuthors(testInputAuthors.map { it.author }) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) - val item = viewModel.uiState.value - assertTrue(item.authorState is AuthorUiState.Success) - assertTrue(item.newsState is NewsUiState.Loading) + val authorState = viewModel.authorUiState.value + val newsUiState = viewModel.newUiState.value + + assertTrue(authorState is AuthorUiState.Success) + assertTrue(newsUiState is NewsUiState.Loading) collectJob.cancel() } @@ -125,21 +134,29 @@ class AuthorViewModelTest { @Test fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { + combine( + viewModel.authorUiState, + viewModel.newUiState, + ::Pair + ).collect() + } authorsRepository.sendAuthors(testInputAuthors.map { it.author }) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) newsRepository.sendNewsResources(sampleNewsResources) - val item = viewModel.uiState.value - assertTrue(item.authorState is AuthorUiState.Success) - assertTrue(item.newsState is NewsUiState.Success) + val authorState = viewModel.authorUiState.value + val newsUiState = viewModel.newUiState.value + + assertTrue(authorState is AuthorUiState.Success) + assertTrue(newsUiState is NewsUiState.Success) collectJob.cancel() } @Test fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() } authorsRepository.sendAuthors(testInputAuthors.map { it.author }) // Set which author IDs are followed, not including 0. @@ -149,7 +166,35 @@ class AuthorViewModelTest { assertEquals( AuthorUiState.Success(followableAuthor = testOutputAuthors[0]), - viewModel.uiState.value.authorState + viewModel.authorUiState.value + ) + + collectJob.cancel() + } + + @Test + fun uiStateAuthor_whenNewsBookmarked_thenShowBookmarkedNews() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.newUiState.collect() } + + authorsRepository.sendAuthors(testInputAuthors.map { it.author }) + newsRepository.sendNewsResources(sampleNewsResources) + + // Set initial bookmarked status to false + userDataRepository.updateNewsResourceBookmark( + newsResourceId = sampleNewsResources.first().id, + bookmarked = false + ) + + viewModel.bookmarkNews( + newsResourceId = sampleNewsResources.first().id, + bookmarked = true + ) + + assertTrue( + (viewModel.newUiState.value as NewsUiState.Success) + .news + .first { it.newsResource.id == sampleNewsResources.first().id } + .isSaved ) collectJob.cancel()