Add news resource cards to for you screen

Change-Id: Ia68950804e304ccd4a9f4ba498dd2cf383315fbd
pull/2/head
Alex Vanyo 4 years ago
parent 506c98cc90
commit 1dc478c441

@ -36,12 +36,10 @@ data class TopicEntity(
val id: Int,
val name: String,
val description: String,
val followed: Boolean,
)
fun TopicEntity.asExternalModel() = Topic(
id = id,
name = name,
description = description,
followed = followed,
)

@ -23,5 +23,4 @@ fun NetworkTopic.asEntity() = TopicEntity(
id = id,
name = name,
description = description,
followed = followed
)

@ -16,14 +16,21 @@
package com.google.samples.apps.nowinandroid.core.domain.repository.fake
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.domain.model.asEntity
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
/**
@ -38,10 +45,25 @@ class FakeNewsRepository @Inject constructor(
) : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> =
flowOf(emptyList())
flow {
emit(
networkJson.decodeFromString<List<NetworkNewsResource>>(FakeDataSource.data)
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
)
}
.flowOn(ioDispatcher)
override fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>> =
flowOf(emptyList())
flow {
emit(
networkJson.decodeFromString<List<NetworkNewsResource>>(FakeDataSource.data)
.filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
)
}
.flowOn(ioDispatcher)
override suspend fun sync() = true
}

@ -56,7 +56,6 @@ class PopulatedNewsResourceKtTest {
id = 3,
name = "name",
description = "description",
followed = true,
)
),
)
@ -83,7 +82,6 @@ class PopulatedNewsResourceKtTest {
id = 3,
name = "name",
description = "description",
followed = true,
)
)
),

@ -0,0 +1,25 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.model.data
/**
* A [topic] with the additional information for whether or not it is followed.
*/
data class FollowableTopic(
val topic: Topic,
val isFollowed: Boolean
)

@ -0,0 +1,25 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.model.data
/**
* A [NewsResource] with the additional information for whether it is saved.
*/
data class SaveableNewsResource(
val newsResource: NewsResource,
val isSaved: Boolean,
)

@ -23,5 +23,4 @@ data class Topic(
val id: Int,
val name: String,
val description: String,
val followed: Boolean = false
)

@ -24,7 +24,6 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@ -42,14 +41,6 @@ class FakeNiANetwork @Inject constructor(
override suspend fun getNewsResources(): List<NetworkNewsResource> =
withContext(ioDispatcher) {
networkJson.decodeFromString<ResourceData>(FakeDataSource.data).resources
networkJson.decodeFromString(FakeDataSource.data)
}
}
/**
* Representation of resources as fetched from [FakeDataSource]
*/
@Serializable
private data class ResourceData(
val resources: List<NetworkNewsResource>
)

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.core.testing.repository
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -37,7 +38,7 @@ class TestNewsRepository : NewsRepository {
filterTopicIds: Set<Int>
): Flow<List<NewsResource>> =
getNewsResourcesStream().map { newsResources ->
newsResources.filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
newsResources.filter { it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty() }
}
/**

@ -192,7 +192,6 @@ private val newsResource = NewsResource(
id = 1,
name = "Name",
description = "Description",
followed = false
)
)
)

@ -24,6 +24,7 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.feature.following.FollowingScreen
import com.google.samples.apps.nowinandroid.feature.following.FollowingUiState
@ -110,7 +111,7 @@ class FollowingScreenTest {
composeTestRule
.onAllNodesWithContentDescription(followingTopicCardUnfollowButton)
.assertCountEquals(testTopics.filter { it.followed }.size)
.assertCountEquals(testTopics.filter { it.isFollowed }.size)
}
@Test
@ -135,22 +136,30 @@ private const val TOPIC_3_NAME = "Tools"
private const val TOPIC_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
private val testTopics = listOf(
Topic(
id = 0,
name = TOPIC_1_NAME,
description = TOPIC_DESC,
followed = true
FollowableTopic(
Topic(
id = 0,
name = TOPIC_1_NAME,
description = TOPIC_DESC,
),
isFollowed = true
),
Topic(
id = 1,
name = TOPIC_2_NAME,
description = TOPIC_DESC
FollowableTopic(
Topic(
id = 1,
name = TOPIC_2_NAME,
description = TOPIC_DESC
),
isFollowed = false
),
Topic(
id = 2,
name = TOPIC_3_NAME,
description = TOPIC_DESC
FollowableTopic(
Topic(
id = 2,
name = TOPIC_3_NAME,
description = TOPIC_DESC
),
isFollowed = false
)
)
private val numberOfUnfollowedTopics = testTopics.filter { !it.followed }.size
private val numberOfUnfollowedTopics = testTopics.filter { !it.isFollowed }.size

@ -45,6 +45,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.ui.NiaLoadingIndicator
import com.google.samples.apps.nowinandroid.core.ui.NiaToolbar
@ -105,10 +106,10 @@ fun FollowingWithTopicsScreen(
LazyColumn(
modifier = modifier
) {
uiState.topics.forEach {
uiState.topics.forEach { followableTopic ->
item {
FollowingTopicCard(
topic = it,
followableTopic = followableTopic,
onTopicClick = onTopicClick,
onFollowButtonClick = onFollowButtonClick
)
@ -124,7 +125,7 @@ fun FollowingErrorScreen() {
@Composable
fun FollowingTopicCard(
topic: Topic,
followableTopic: FollowableTopic,
onTopicClick: () -> Unit,
onFollowButtonClick: (Int, Boolean) -> Unit,
) {
@ -147,13 +148,13 @@ fun FollowingTopicCard(
.weight(1f)
.clickable { onTopicClick() }
) {
TopicTitle(topicName = topic.name)
TopicDescription(topicDescription = topic.description)
TopicTitle(topicName = followableTopic.topic.name)
TopicDescription(topicDescription = followableTopic.topic.description)
}
FollowButton(
topicId = topic.id,
topicId = followableTopic.topic.id,
onClick = onFollowButtonClick,
isFollowed = topic.followed
isFollowed = followableTopic.isFollowed
)
}
}
@ -244,10 +245,13 @@ fun TopicCardPreview() {
NiaTheme {
Surface {
FollowingTopicCard(
Topic(
id = 0,
name = "Compose",
description = "Description"
FollowableTopic(
Topic(
id = 0,
name = "Compose",
description = "Description"
),
isFollowed = false
),
onTopicClick = {},
onFollowButtonClick = { _, _ -> }

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.following
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -39,10 +40,10 @@ class FollowingViewModel @Inject constructor(
) : ViewModel() {
private val followedTopicIdsStream = topicsRepository.getFollowedTopicIdsStream()
.catch { FollowingState.Error }
.map { followedTopics ->
.map<Set<Int>, FollowingState> { followedTopics ->
FollowingState.Topics(topics = followedTopics)
}
.catch { emit(FollowingState.Error) }
val uiState: StateFlow<FollowingUiState> = combine(
followedTopicIdsStream,
@ -70,15 +71,14 @@ class FollowingViewModel @Inject constructor(
private fun mapFollowedAndUnfollowedTopics(topics: List<Topic>): Flow<FollowingUiState.Topics> =
topicsRepository.getFollowedTopicIdsStream().map { followedTopicIds ->
FollowingUiState.Topics(
topics =
topics.map {
Topic(
it.id,
it.name,
it.description,
followedTopicIds.contains(it.id)
)
}.sortedBy { it.name }
topics = topics
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in followedTopicIds,
)
}
.sortedBy { it.topic.name }
)
}
}
@ -90,6 +90,6 @@ private sealed interface FollowingState {
sealed interface FollowingUiState {
object Loading : FollowingUiState
data class Topics(val topics: List<Topic>) : FollowingUiState
data class Topics(val topics: List<FollowableTopic>) : FollowingUiState
object Error : FollowingUiState
}

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.following
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule
@ -63,13 +64,13 @@ class FollowingViewModelTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics)
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[0].id))
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
awaitItem()
viewModel.followTopic(
followedTopicId = testInputTopics[1].id,
followed = !testInputTopics[1].followed
followedTopicId = testInputTopics[1].topic.id,
followed = true
)
assertEquals(
@ -85,19 +86,21 @@ class FollowingViewModelTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(testOutputTopics)
topicsRepository.sendTopics(testOutputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(
setOf(testOutputTopics[0].id, testOutputTopics[1].id)
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
)
awaitItem()
viewModel.followTopic(
followedTopicId = testOutputTopics[1].id,
followed = !testOutputTopics[1].followed
followedTopicId = testOutputTopics[1].topic.id,
followed = false
)
assertEquals(
FollowingUiState.Topics(topics = testInputTopics),
FollowingUiState.Topics(
topics = testInputTopics
),
awaitItem()
)
cancel()
@ -111,41 +114,55 @@ private const val TOPIC_3_NAME = "Compose"
private const val TOPIC_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
private val testInputTopics = listOf(
Topic(
id = 0,
name = TOPIC_1_NAME,
description = TOPIC_DESC,
followed = true
FollowableTopic(
Topic(
id = 0,
name = TOPIC_1_NAME,
description = TOPIC_DESC,
),
isFollowed = true
),
Topic(
id = 1,
name = TOPIC_2_NAME,
description = TOPIC_DESC
FollowableTopic(
Topic(
id = 1,
name = TOPIC_2_NAME,
description = TOPIC_DESC
),
isFollowed = false
),
Topic(
id = 2,
name = TOPIC_3_NAME,
description = TOPIC_DESC
FollowableTopic(
Topic(
id = 2,
name = TOPIC_3_NAME,
description = TOPIC_DESC
),
isFollowed = false
)
)
private val testOutputTopics = listOf(
Topic(
id = 0,
name = TOPIC_1_NAME,
description = TOPIC_DESC,
followed = true
FollowableTopic(
Topic(
id = 0,
name = TOPIC_1_NAME,
description = TOPIC_DESC,
),
isFollowed = true
),
Topic(
id = 1,
name = TOPIC_2_NAME,
description = TOPIC_DESC,
followed = true
FollowableTopic(
Topic(
id = 1,
name = TOPIC_2_NAME,
description = TOPIC_DESC
),
isFollowed = true
),
Topic(
id = 2,
name = TOPIC_3_NAME,
description = TOPIC_DESC,
followed = false,
FollowableTopic(
Topic(
id = 2,
name = TOPIC_3_NAME,
description = TOPIC_DESC
),
isFollowed = false
)
)

@ -53,6 +53,7 @@ dependencies {
androidTestImplementation project(':core-testing')
implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.datetime
implementation libs.androidx.hilt.navigation.compose
implementation libs.androidx.lifecycle.viewModelCompose

@ -26,6 +26,7 @@ import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import org.junit.Rule
import org.junit.Test
@ -40,7 +41,8 @@ class ForYouScreenTest {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
@ -56,27 +58,37 @@ class ForYouScreenTest {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to false,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
topics = listOf(
FollowableTopic(
topic = Topic(
id = 0,
name = "Headlines",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 1,
name = "UI",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 2,
name = "Tools",
description = "",
),
isFollowed = false
),
),
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
@ -110,27 +122,37 @@ class ForYouScreenTest {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to true,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
topics = listOf(
FollowableTopic(
topic = Topic(
id = 0,
name = "Headlines",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 1,
name = "UI",
description = ""
),
isFollowed = true
),
FollowableTopic(
topic = Topic(
id = 2,
name = "Tools",
description = "",
),
isFollowed = false
),
),
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
@ -39,8 +40,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.flowlayout.FlowRow
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 com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded
import com.google.samples.apps.nowinandroid.core.ui.NiaLoadingIndicator
import kotlinx.datetime.Instant
@Composable
fun ForYouRoute(
@ -53,7 +60,8 @@ fun ForYouRoute(
modifier = modifier,
uiState = uiState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
saveFollowedTopics = viewModel::saveFollowedTopics
saveFollowedTopics = viewModel::saveFollowedTopics,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved
)
}
@ -62,6 +70,7 @@ fun ForYouScreen(
uiState: ForYouFeedUiState,
onTopicCheckedChanged: (Int, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
@ -81,9 +90,15 @@ fun ForYouScreen(
is ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection -> Unit
}
// items(uiState.feed) { _: NewsResource ->
// // TODO: News item
// }
items(uiState.feed) { (newsResource: NewsResource, isBookmarked: Boolean) ->
NewsResourceCardExpanded(
newsResource = newsResource,
isBookmarked = isBookmarked,
onToggleBookmark = {
onNewsResourcesCheckedChanged(newsResource.id, !isBookmarked)
}
)
}
}
}
}
@ -104,7 +119,7 @@ private fun LazyListScope.TopicSelection(
crossAxisSpacing = 8.dp,
modifier = Modifier.padding(horizontal = 40.dp)
) {
uiState.selectedTopics.forEach { (topic, isSelected) ->
uiState.topics.forEach { (topic, isSelected) ->
key(topic.id) {
// TODO: Add toggleable semantics
OutlinedButton(
@ -149,7 +164,8 @@ fun ForYouScreenLoading() {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
@ -158,27 +174,104 @@ fun ForYouScreenLoading() {
fun ForYouScreenTopicSelection() {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to true,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
topics = listOf(
FollowableTopic(
topic = Topic(
id = 0,
name = "Headlines",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 1,
name = "UI",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 2,
name = "Tools",
description = "",
),
isFollowed = false
),
),
feed = emptyList()
feed = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = 1,
episodeId = 52,
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series " +
"and everything the Android Developers YouTube channel has to offer. " +
"During the Android Developer Summit, our YouTube channel reached 1 " +
"million subscribers! Heres a small video to thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
)
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = 2,
episodeId = 52,
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = 1,
name = "UI",
description = ""
),
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = 3,
episodeId = 52,
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = 1,
name = "UI",
description = ""
),
),
authors = emptyList()
),
isSaved = false
),
)
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
@ -190,6 +283,7 @@ fun PopulatedFeed() {
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}

@ -24,8 +24,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
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.Topic
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.feature.foryou.util.saveable
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -51,7 +52,7 @@ class ForYouViewModel @Inject constructor(
FollowedTopicsState.None
} else {
FollowedTopicsState.FollowedTopics(
topics = followedTopics
topicIds = followedTopics
)
}
}
@ -61,6 +62,16 @@ class ForYouViewModel @Inject constructor(
initialValue = FollowedTopicsState.Unknown
)
/**
* TODO: Temporary saving of news resources persisted through process death with a
* [SavedStateHandle].
*
* This should be persisted to disk instead.
*/
private var savedNewsResources by savedStateHandle.saveable {
mutableStateOf<Set<Int>>(emptySet())
}
/**
* The in-progress set of topics to be selected, persisted through process death with a
* [SavedStateHandle].
@ -73,15 +84,26 @@ class ForYouViewModel @Inject constructor(
followedTopicsStateFlow,
topicsRepository.getTopicsStream(),
snapshotFlow { inProgressTopicSelection },
) { followedTopicsUserState, availableTopics, inProgressTopicSelection ->
snapshotFlow { savedNewsResources }
) { followedTopicsUserState, availableTopics, inProgressTopicSelection, savedNewsResources ->
fun mapToSaveableFeed(feed: List<NewsResource>): List<SaveableNewsResource> =
feed.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResources.contains(newsResource.id)
)
}
when (followedTopicsUserState) {
// If we don't know the current selection state, just emit loading.
FollowedTopicsState.Unknown -> flowOf<ForYouFeedUiState>(ForYouFeedUiState.Loading)
// If the user has followed topics, use those followed topics to populate the feed
is FollowedTopicsState.FollowedTopics -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = followedTopicsUserState.topics
filterTopicIds = followedTopicsUserState.topicIds
)
.map(::mapToSaveableFeed)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = feed
@ -94,10 +116,14 @@ class ForYouViewModel @Inject constructor(
newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection
)
.map(::mapToSaveableFeed)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = availableTopics.map { topic ->
topic to (topic.id in inProgressTopicSelection)
topics = availableTopics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in inProgressTopicSelection
)
},
feed = feed
)
@ -127,6 +153,17 @@ class ForYouViewModel @Inject constructor(
}
}
fun updateNewsResourceSaved(newsResourceId: Int, isChecked: Boolean) {
withMutableSnapshot {
savedNewsResources =
if (isChecked) {
savedNewsResources + newsResourceId
} else {
savedNewsResources - newsResourceId
}
}
}
fun saveFollowedTopics() {
if (inProgressTopicSelection.isEmpty()) return
@ -152,10 +189,10 @@ private sealed interface FollowedTopicsState {
object None : FollowedTopicsState
/**
* The user has followed the given (non-empty) set of [topics].
* The user has followed the given (non-empty) set of [topicIds].
*/
data class FollowedTopics(
val topics: Set<Int>,
val topicIds: Set<Int>,
) : FollowedTopicsState
}
@ -177,23 +214,23 @@ sealed interface ForYouFeedUiState {
/**
* The list of news resources contained in this [PopulatedFeed].
*/
val feed: List<NewsResource>
val feed: List<SaveableNewsResource>
/**
* The feed, along with a list of topics that can be selected.
*/
data class FeedWithTopicSelection(
val selectedTopics: List<Pair<Topic, Boolean>>,
override val feed: List<NewsResource>
val topics: List<FollowableTopic>,
override val feed: List<SaveableNewsResource>
) : PopulatedFeed {
val canSaveSelectedTopics: Boolean = selectedTopics.any { it.second }
val canSaveSelectedTopics: Boolean = topics.any { it.isFollowed }
}
/**
* Just the feed.
*/
data class FeedWithoutTopicSelection(
override val feed: List<NewsResource>
override val feed: List<SaveableNewsResource>
) : PopulatedFeed
}
}

@ -18,11 +18,16 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
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 com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
@ -91,26 +96,35 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to false,
Topic(
id = 2,
name = "Tools",
description = "",
) to false
topics = listOf(
FollowableTopic(
topic = Topic(
id = 0,
name = "Headlines",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 1,
name = "UI",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 2,
name = "Tools",
description = "",
),
isFollowed = false
),
),
feed = emptyList()
),
@ -127,11 +141,16 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(setOf(0, 1))
newsRepository.sendNewsResources(emptyList())
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = emptyList()
feed = sampleNewsResources.map {
SaveableNewsResource(
newsResource = it,
isSaved = false
)
}
),
awaitItem()
)
@ -146,31 +165,49 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to true,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
topics = listOf(
FollowableTopic(
topic = Topic(
id = 0,
name = "Headlines",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 1,
name = "UI",
description = ""
),
isFollowed = true
),
FollowableTopic(
topic = Topic(
id = 2,
name = "Tools",
description = "",
),
isFollowed = false
)
),
feed = emptyList()
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
)
),
awaitItem()
)
@ -185,7 +222,7 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
@ -195,22 +232,31 @@ class ForYouViewModelTest {
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to false,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
topics = listOf(
FollowableTopic(
topic = Topic(
id = 0,
name = "Headlines",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 1,
name = "UI",
description = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 2,
name = "Tools",
description = "",
),
isFollowed = false
)
),
feed = emptyList()
),
@ -227,7 +273,7 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
@ -237,7 +283,16 @@ class ForYouViewModelTest {
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = emptyList()
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
)
),
awaitItem()
)
@ -245,6 +300,35 @@ class ForYouViewModelTest {
cancel()
}
}
@Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(setOf(1))
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved(2, true)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = true
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
)
),
awaitItem()
)
cancel()
}
}
}
private val sampleTopics = listOf(
@ -264,3 +348,62 @@ private val sampleTopics = listOf(
description = ""
)
)
private val sampleNewsResources = listOf(
NewsResource(
id = 1,
episodeId = 52,
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
)
),
authors = emptyList()
),
NewsResource(
id = 2,
episodeId = 52,
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed with Paging. " +
"Transformations like inserting separators, when to create a new pager, and " +
"customisation options for consuming PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = 1,
name = "UI",
description = ""
),
),
authors = emptyList()
),
NewsResource(
id = 3,
episodeId = 52,
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = 1,
name = "UI",
description = ""
),
),
authors = emptyList()
),
)

Loading…
Cancel
Save