Merge pull request #301 from android/caren/topics_bookmarks

Enable bookmarks on topics page
pull/307/head
Caren 2 years ago committed by GitHub
commit 4a1fb58c34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

@ -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<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.
private val followedTopicIdsStream: Flow<Result<Set<String>>> =
val followedTopicIdsStream: Flow<Set<String>> =
userDataRepository.userDataStream
.map { it.followedTopics }
.asResult()
// Observe topic information
private val topic: Flow<Result<Topic>> = topicsRepository.getTopic(topicId).asResult()
// Observe the News for this topic
private val newsStream: Flow<Result<List<NewsResource>>> =
newsRepository.getNewsResourcesStream(
filterTopicIds = setOf(element = topicId),
).asResult()
val uiState: StateFlow<TopicScreenUiState> =
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<Topic> = 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<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 {
@ -114,12 +178,7 @@ sealed interface TopicUiState {
}
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 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.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()

Loading…
Cancel
Save