Merge pull request #301 from android/caren/topics_bookmarks

Enable bookmarks on topics page
pull/1837/head
Caren 3 years ago committed by GitHub
commit 583b0b189d

@ -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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.NewsResourceType.Video
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.model.data.Topic
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
@ -56,10 +57,11 @@ class TopicScreenTest {
fun niaLoadingWheel_whenScreenIsLoading_showLoading() { fun niaLoadingWheel_whenScreenIsLoading_showLoading() {
composeTestRule.setContent { composeTestRule.setContent {
TopicScreen( TopicScreen(
topicState = TopicUiState.Loading, topicUiState = TopicUiState.Loading,
newsState = NewsUiState.Loading, newsUiState = NewsUiState.Loading,
onBackClick = { }, onBackClick = { },
onFollowClick = { } onFollowClick = { },
onBookmarkChanged = { _, _ -> },
) )
} }
@ -73,10 +75,11 @@ class TopicScreenTest {
val testTopic = testTopics.first() val testTopic = testTopics.first()
composeTestRule.setContent { composeTestRule.setContent {
TopicScreen( TopicScreen(
topicState = TopicUiState.Success(testTopic), topicUiState = TopicUiState.Success(testTopic),
newsState = NewsUiState.Loading, newsUiState = NewsUiState.Loading,
onBackClick = { }, onBackClick = { },
onFollowClick = { } onFollowClick = { },
onBookmarkChanged = { _, _ -> },
) )
} }
@ -95,10 +98,18 @@ class TopicScreenTest {
fun news_whenTopicIsLoading_isNotShown() { fun news_whenTopicIsLoading_isNotShown() {
composeTestRule.setContent { composeTestRule.setContent {
TopicScreen( TopicScreen(
topicState = TopicUiState.Loading, topicUiState = TopicUiState.Loading,
newsState = NewsUiState.Success(sampleNewsResources), newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { }, onBackClick = { },
onFollowClick = { } onFollowClick = { },
onBookmarkChanged = { _, _ -> },
) )
} }
@ -107,15 +118,24 @@ class TopicScreenTest {
.onNodeWithContentDescription(topicLoading) .onNodeWithContentDescription(topicLoading)
.assertExists() .assertExists()
} }
@Test @Test
fun news_whenSuccessAndTopicIsSuccess_isShown() { fun news_whenSuccessAndTopicIsSuccess_isShown() {
val testTopic = testTopics.first() val testTopic = testTopics.first()
composeTestRule.setContent { composeTestRule.setContent {
TopicScreen( TopicScreen(
topicState = TopicUiState.Success(testTopic), topicUiState = TopicUiState.Success(testTopic),
newsState = NewsUiState.Success(sampleNewsResources), newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { }, onBackClick = { },
onFollowClick = { } onFollowClick = { },
onBookmarkChanged = { _, _ -> },
) )
} }

@ -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.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources 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.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
@ -66,24 +67,27 @@ fun TopicRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(), 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( TopicScreen(
topicState = uiState.topicState, topicUiState = topicUiState,
newsState = uiState.newsState, newsUiState = newsUiState,
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle, onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews,
) )
} }
@VisibleForTesting @VisibleForTesting
@Composable @Composable
internal fun TopicScreen( internal fun TopicScreen(
topicState: TopicUiState, topicUiState: TopicUiState,
newsState: NewsUiState, newsUiState: NewsUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit, onFollowClick: (Boolean) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@ -93,7 +97,7 @@ internal fun TopicScreen(
item { item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
} }
when (topicState) { when (topicUiState) {
Loading -> item { Loading -> item {
NiaLoadingWheel( NiaLoadingWheel(
modifier = modifier, modifier = modifier,
@ -106,14 +110,15 @@ internal fun TopicScreen(
TopicToolbar( TopicToolbar(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
uiState = topicState.followableTopic, uiState = topicUiState.followableTopic,
) )
} }
TopicBody( TopicBody(
name = topicState.followableTopic.topic.name, name = topicUiState.followableTopic.topic.name,
description = topicState.followableTopic.topic.longDescription, description = topicUiState.followableTopic.topic.longDescription,
news = newsState, news = newsUiState,
imageUrl = topicState.followableTopic.topic.imageUrl imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged
) )
} }
} }
@ -127,14 +132,15 @@ private fun LazyListScope.TopicBody(
name: String, name: String,
description: String, description: String,
news: NewsUiState, news: NewsUiState,
imageUrl: String imageUrl: String,
onBookmarkChanged: (String, Boolean) -> Unit
) { ) {
// TODO: Show icon if available // TODO: Show icon if available
item { item {
TopicHeader(name, description, imageUrl) TopicHeader(name, description, imageUrl)
} }
TopicCards(news) TopicCards(news, onBookmarkChanged)
} }
@Composable @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) { when (news) {
is NewsUiState.Success -> { is NewsUiState.Success -> {
newsResourceCardItems( newsResourceCardItems(
items = news.news, items = news.news,
newsResourceMapper = { it }, newsResourceMapper = { it.newsResource },
isBookmarkedMapper = { /* TODO */ false }, isBookmarkedMapper = { it.isSaved },
onToggleBookmark = { /* TODO */ }, onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) },
itemModifier = Modifier.padding(24.dp) itemModifier = Modifier.padding(24.dp)
) )
} }
@ -188,7 +197,7 @@ private fun TopicBodyPreview() {
LazyColumn { LazyColumn {
TopicBody( TopicBody(
"Jetpack Compose", "Lorem ipsum maximum", "Jetpack Compose", "Lorem ipsum maximum",
NewsUiState.Success(emptyList()), "" NewsUiState.Success(emptyList()), "", { _, _ -> }
) )
} }
} }
@ -237,10 +246,18 @@ fun TopicScreenPopulated() {
NiaTheme { NiaTheme {
NiaBackground { NiaBackground {
TopicScreen( TopicScreen(
topicState = TopicUiState.Success(FollowableTopic(previewTopics[0], false)), topicUiState = TopicUiState.Success(FollowableTopic(previewTopics[0], false)),
newsState = NewsUiState.Success(previewNewsResources), newsUiState = NewsUiState.Success(
previewNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = {}, onBackClick = {},
onFollowClick = {} onFollowClick = {},
onBookmarkChanged = { _, _ -> },
) )
} }
} }
@ -252,10 +269,11 @@ fun TopicScreenLoading() {
NiaTheme { NiaTheme {
NiaBackground { NiaBackground {
TopicScreen( TopicScreen(
topicState = TopicUiState.Loading, topicUiState = TopicUiState.Loading,
newsState = NewsUiState.Loading, newsUiState = NewsUiState.Loading,
onBackClick = {}, onBackClick = {},
onFollowClick = {} onFollowClick = {},
onBookmarkChanged = { _, _ -> },
) )
} }
} }

@ -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.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.model.data.Topic
import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult 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]) private val topicId: String = checkNotNull(savedStateHandle[TopicDestination.topicIdArg])
val topicUiState: StateFlow<TopicUiState> = topicUiStateStream(
topicId = topicId,
userDataRepository = userDataRepository,
topicsRepository = topicsRepository
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TopicUiState.Loading
)
val newUiState: StateFlow<NewsUiState> = 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<TopicUiState> {
// Observe the followed topics, as they could change over time. // Observe the followed topics, as they could change over time.
private val followedTopicIdsStream: Flow<Result<Set<String>>> = val followedTopicIdsStream: Flow<Set<String>> =
userDataRepository.userDataStream userDataRepository.userDataStream
.map { it.followedTopics } .map { it.followedTopics }
.asResult()
// Observe topic information // Observe topic information
private val topic: Flow<Result<Topic>> = topicsRepository.getTopic(topicId).asResult() val topicStream: Flow<Topic> = topicsRepository.getTopic(
id = topicId
// Observe the News for this topic )
private val newsStream: Flow<Result<List<NewsResource>>> =
newsRepository.getNewsResourcesStream( return combine(
filterTopicIds = setOf(element = topicId), followedTopicIdsStream,
).asResult() topicStream,
::Pair
val uiState: StateFlow<TopicScreenUiState> = )
combine( .asResult()
followedTopicIdsStream, .map { followedTopicToTopicResult ->
topic, when (followedTopicToTopicResult) {
newsStream is Result.Success -> {
) { followedTopicsResult, topicResult, newsResult -> val (followedTopics, topic) = followedTopicToTopicResult.data
val topic: TopicUiState = val followed = followedTopics.contains(topicId)
if (topicResult is Result.Success && followedTopicsResult is Result.Success) {
val followed = followedTopicsResult.data.contains(topicId)
TopicUiState.Success( TopicUiState.Success(
followableTopic = FollowableTopic( followableTopic = FollowableTopic(
topic = topicResult.data, topic = topic,
isFollowed = followed isFollowed = followed
) )
) )
} else if ( }
topicResult is Result.Loading || followedTopicsResult is Result.Loading is Result.Loading -> {
) {
TopicUiState.Loading TopicUiState.Loading
} else { }
is Result.Error -> {
TopicUiState.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) { private fun newsUiStateStream(
viewModelScope.launch { topicId: String,
userDataRepository.toggleFollowedTopicId(topicId, followed) newsRepository: NewsRepository,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> {
// Observe news
val newsStream: Flow<List<NewsResource>> = newsRepository.getNewsResourcesStream(
filterAuthorIds = emptySet(),
filterTopicIds = setOf(element = topicId),
)
// 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 TopicUiState { sealed interface TopicUiState {
@ -114,12 +178,7 @@ sealed interface TopicUiState {
} }
sealed interface NewsUiState { sealed interface NewsUiState {
data class Success(val news: List<NewsResource>) : NewsUiState data class Success(val news: List<SaveableNewsResource>) : NewsUiState
object Error : NewsUiState object Error : NewsUiState
object Loading : NewsUiState object Loading : NewsUiState
} }
data class TopicScreenUiState(
val topicState: TopicUiState,
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.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -64,15 +65,15 @@ class TopicViewModelTest {
} }
@Test @Test
fun uiStateAuthor_whenSuccess_matchesTopicFromRepository() = runTest { fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic)) topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = viewModel.uiState.value val item = viewModel.topicUiState.value
assertTrue(item.topicState is TopicUiState.Success) assertTrue(item is TopicUiState.Success)
val successTopicState = item.topicState as TopicUiState.Success val successTopicState = item as TopicUiState.Success
val topicFromRepository = topicsRepository.getTopic( val topicFromRepository = topicsRepository.getTopic(
testInputTopics[0].topic.id testInputTopics[0].topic.id
).first() ).first()
@ -84,20 +85,20 @@ class TopicViewModelTest {
@Test @Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest { fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState) assertEquals(NewsUiState.Loading, viewModel.newUiState.value)
} }
@Test @Test
fun uiStateTopic_whenInitialized_thenShowLoading() = runTest { fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {
assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState) assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)
} }
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest { 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)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState) assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)
collectJob.cancel() collectJob.cancel()
} }
@ -105,13 +106,15 @@ class TopicViewModelTest {
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() = fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =
runTest { runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = viewModel.uiState.value val topicUiState = viewModel.topicUiState.value
assertTrue(item.topicState is TopicUiState.Success) val newsUiState = viewModel.newUiState.value
assertTrue(item.newsState is NewsUiState.Loading)
assertTrue(topicUiState is TopicUiState.Success)
assertTrue(newsUiState is NewsUiState.Loading)
collectJob.cancel() collectJob.cancel()
} }
@ -119,21 +122,28 @@ class TopicViewModelTest {
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() = fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest { 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 }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
val item = viewModel.uiState.value val topicUiState = viewModel.topicUiState.value
assertTrue(item.topicState is TopicUiState.Success) val newsUiState = viewModel.newUiState.value
assertTrue(item.newsState is NewsUiState.Success)
assertTrue(topicUiState is TopicUiState.Success)
assertTrue(newsUiState is NewsUiState.Success)
collectJob.cancel() collectJob.cancel()
} }
@Test @Test
fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest { 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 }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
// Set which topic IDs are followed, not including 0. // Set which topic IDs are followed, not including 0.
@ -143,7 +153,7 @@ class TopicViewModelTest {
assertEquals( assertEquals(
TopicUiState.Success(followableTopic = testOutputTopics[0]), TopicUiState.Success(followableTopic = testOutputTopics[0]),
viewModel.uiState.value.topicState viewModel.topicUiState.value
) )
collectJob.cancel() collectJob.cancel()

Loading…
Cancel
Save