Merge "Enable bookmarks on authors page" into main

pull/205/head
TJ Dahunsi 2 years ago committed by Gerrit Code Review
commit 57cb02edc1

@ -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 = { _, _ -> },
)
}

@ -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 = { _, _ -> },
)
}
}

@ -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<AuthorUiState> = authorUiStateStream(
authorId = authorId,
userDataRepository = userDataRepository,
authorsRepository = authorsRepository
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AuthorUiState.Loading
)
val newUiState: StateFlow<NewsUiState> = 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<AuthorUiState> {
// Observe the followed authors, as they could change over time.
private val followedAuthorIdsStream: Flow<Result<Set<String>>> =
val followedAuthorIdsStream: Flow<Set<String>> =
userDataRepository.userDataStream
.map { it.followedAuthors }
.asResult()
// Observe author information
private val author: Flow<Result<Author>> = authorsRepository.getAuthorStream(
val authorStream: Flow<Author> = authorsRepository.getAuthorStream(
id = authorId
).asResult()
// Observe the News for this author
private val newsStream: Flow<Result<List<NewsResource>>> =
newsRepository.getNewsResourcesStream(
filterAuthorIds = setOf(element = authorId),
filterTopicIds = emptySet()
).asResult()
val uiState: StateFlow<AuthorScreenUiState> =
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<NewsUiState> {
// Observe news
val newsStream: Flow<List<NewsResource>> = newsRepository.getNewsResourcesStream(
filterAuthorIds = setOf(element = authorId),
filterTopicIds = emptySet()
)
// Observe bookmarks
val bookmarkStream: Flow<Set<String>> = 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<NewsResource>) : NewsUiState
data class Success(val news: List<SaveableNewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}
data class AuthorScreenUiState(
val authorState: AuthorUiState,
val newsState: NewsUiState
)

@ -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()

Loading…
Cancel
Save