Merge pull request #508 from android/mm/chipcolor

Update topic chips to show a different color based on whether the user is following that topic
pull/524/head
MagicalMeghan 2 years ago committed by GitHub
commit de5ceb40ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -18,52 +18,43 @@ package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
/**
* A use case responsible for obtaining news resources with their associated bookmarked (also known
* as "saved") state.
*/
class GetSaveableNewsResourcesUseCase @Inject constructor(
class GetUserNewsResourcesUseCase @Inject constructor(
private val newsRepository: NewsRepository,
userDataRepository: UserDataRepository
private val userDataRepository: UserDataRepository
) {
private val bookmarkedNewsResources = userDataRepository.userData.map {
it.bookmarkedNewsResources
}
/**
* Returns a list of SaveableNewsResources which match the supplied set of topic ids.
* Returns a list of UserNewsResources which match the supplied set of topic ids.
*
* @param filterTopicIds - A set of topic ids used to filter the list of news resources. If
* this is empty the list of news resources will not be filtered.
*/
operator fun invoke(
filterTopicIds: Set<String> = emptySet()
): Flow<List<SaveableNewsResource>> =
): Flow<List<UserNewsResource>> =
if (filterTopicIds.isEmpty()) {
newsRepository.getNewsResources()
} else {
newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
}.mapToSaveableNewsResources(bookmarkedNewsResources)
}.mapToUserNewsResources(userDataRepository.userData)
}
private fun Flow<List<NewsResource>>.mapToSaveableNewsResources(
savedNewsResourceIdsStream: Flow<Set<String>>
): Flow<List<SaveableNewsResource>> =
private fun Flow<List<NewsResource>>.mapToUserNewsResources(
userDataStream: Flow<UserData>
): Flow<List<UserNewsResource>> =
filterNot { it.isEmpty() }
.combine(savedNewsResourceIdsStream) { newsResources, savedNewsResourceIds ->
newsResources.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResourceIds.contains(newsResource.id)
)
}
.combine(userDataStream) { newsResources, userData ->
newsResources.mapToUserNewsResources(userData)
}

@ -17,11 +17,27 @@
package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
/**
* A [topic] with the additional information for whether or not it is followed.
*/
data class FollowableTopic(
data class FollowableTopic( // TODO consider changing to UserTopic and flattening
val topic: Topic,
val isFollowed: Boolean
)
val previewFollowableTopics = listOf(
FollowableTopic(
previewTopics[0],
isFollowed = false
),
FollowableTopic(
previewTopics[1],
isFollowed = true
),
FollowableTopic(
previewTopics[2],
isFollowed = false
)
)

@ -1,27 +0,0 @@
/*
* 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.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
/**
* A [NewsResource] with the additional information for whether it is saved.
*/
data class SaveableNewsResource(
val newsResource: NewsResource,
val isSaved: Boolean,
)

@ -0,0 +1,129 @@
/*
* 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.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
/* ktlint-disable max-line-length */
/**
* A [NewsResource] with additional user information such as whether the user is following the
* news resource's topics and whether they have saved (bookmarked) this news resource.
*/
data class UserNewsResource internal constructor(
val id: String,
val title: String,
val content: String,
val url: String,
val headerImageUrl: String?,
val publishDate: Instant,
val type: NewsResourceType,
val followableTopics: List<FollowableTopic>,
val isSaved: Boolean
) {
constructor(newsResource: NewsResource, userData: UserData) : this(
id = newsResource.id,
title = newsResource.title,
content = newsResource.content,
url = newsResource.url,
headerImageUrl = newsResource.headerImageUrl,
publishDate = newsResource.publishDate,
type = newsResource.type,
followableTopics = newsResource.topics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = userData.followedTopics.contains(topic.id)
)
},
isSaved = userData.bookmarkedNewsResources.contains(newsResource.id)
)
}
fun List<NewsResource>.mapToUserNewsResources(userData: UserData): List<UserNewsResource> {
return map { UserNewsResource(it, userData) }
}
val previewUserNewsResources = listOf(
UserNewsResource(
id = "1",
title = "Android Basics with Compose",
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,
dayOfMonth = 4,
hour = 23,
minute = 0,
second = 0,
nanosecond = 0
).toInstant(TimeZone.UTC),
type = Codelab,
followableTopics = listOf(previewFollowableTopics[1]),
isSaved = true
),
UserNewsResource(
id = "2",
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",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
followableTopics = listOf(previewFollowableTopics[0], previewFollowableTopics[1]),
isSaved = false
),
UserNewsResource(
id = "3",
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",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
followableTopics = listOf(previewFollowableTopics[2]),
isSaved = false
),
UserNewsResource(
id = "4",
title = "New Jetpack Release",
content = "New Jetpack release includes updates to libraries such as CameraX, Benchmark, and" +
"more!",
url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown,
followableTopics = listOf(previewFollowableTopics[2]),
isSaved = true
)
)

@ -16,12 +16,13 @@
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
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.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
@ -30,7 +31,7 @@ import kotlinx.datetime.Instant
import org.junit.Rule
import org.junit.Test
class GetSaveableNewsResourcesUseCaseTest {
class GetUserNewsResourcesUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@ -38,47 +39,48 @@ class GetSaveableNewsResourcesUseCaseTest {
private val newsRepository = TestNewsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetSaveableNewsResourcesUseCase(newsRepository, userDataRepository)
val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository)
@Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the saveable news resources stream.
val saveableNewsResources = useCase()
// Obtain the user news resources stream.
val userNewsResources = useCase()
// Send some news resources and bookmarks.
// Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(
setOf(sampleNewsResources[0].id, sampleNewsResources[2].id)
// Construct the test user data with bookmarks and followed topics.
val userData = emptyUserData.copy(
bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),
followedTopics = setOf(sampleTopic1.id)
)
userDataRepository.setUserData(userData)
// Check that the correct news resources are returned with their bookmarked state.
assertEquals(
listOf(
SaveableNewsResource(sampleNewsResources[0], true),
SaveableNewsResource(sampleNewsResources[1], false),
SaveableNewsResource(sampleNewsResources[2], true)
),
saveableNewsResources.first()
sampleNewsResources.mapToUserNewsResources(userData),
userNewsResources.first()
)
}
@Test
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of saveable news resources for the given topic id.
val saveableNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id))
// Obtain a stream of user news resources for the given topic id.
val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id))
// Send some news resources and bookmarks.
// Send test data into the repositories.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(setOf())
userDataRepository.setUserData(emptyUserData)
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.map { SaveableNewsResource(it, false) },
saveableNewsResources.first()
.mapToUserNewsResources(emptyUserData),
userNewsResources.first()
)
}
}

@ -0,0 +1,106 @@
/*
* Copyright 2023 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.domain
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.datetime.Clock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class UserNewsResourceTest {
/**
* Given: Some user data and news resources
* When: They are combined using `UserNewsResource.from`
* Then: The correct UserNewsResources are constructed
*/
@Test
fun userNewsResourcesAreConstructedFromNewsResourcesAndUserData() {
val newsResource1 = NewsResource(
id = "N1",
title = "Test news title",
content = "Test news content",
url = "Test URL",
headerImageUrl = "Test image URL",
publishDate = Clock.System.now(),
type = Article,
topics = listOf(
Topic(
id = "T1",
name = "Topic 1",
shortDescription = "Topic 1 short description",
longDescription = "Topic 1 long description",
url = "Topic 1 URL",
imageUrl = "Topic 1 image URL"
),
Topic(
id = "T2",
name = "Topic 2",
shortDescription = "Topic 2 short description",
longDescription = "Topic 2 long description",
url = "Topic 2 URL",
imageUrl = "Topic 2 image URL"
),
)
)
val userData = UserData(
bookmarkedNewsResources = setOf("N1"),
followedTopics = setOf("T1"),
themeBrand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM,
shouldHideOnboarding = true
)
val userNewsResource = UserNewsResource(newsResource1, userData)
// Check that the simple field mappings have been done correctly.
assertEquals(newsResource1.id, userNewsResource.id)
assertEquals(newsResource1.title, userNewsResource.title)
assertEquals(newsResource1.content, userNewsResource.content)
assertEquals(newsResource1.url, userNewsResource.url)
assertEquals(newsResource1.headerImageUrl, userNewsResource.headerImageUrl)
assertEquals(newsResource1.publishDate, userNewsResource.publishDate)
// Check that each Topic has been converted to a FollowedTopic correctly.
assertEquals(newsResource1.topics.size, userNewsResource.followableTopics.size)
for (topic in newsResource1.topics) {
// Construct the expected FollowableTopic.
val followableTopic = FollowableTopic(
topic = topic,
isFollowed = userData.followedTopics.contains(topic.id)
)
assertTrue(userNewsResource.followableTopics.contains(followableTopic))
}
// Check that the saved flag is set correctly.
assertEquals(
userData.bookmarkedNewsResources.contains(newsResource1.id),
userNewsResource.isSaved
)
}
}

@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filterNotNull
private val emptyUserData = UserData(
val emptyUserData = UserData(
bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
@ -98,4 +98,11 @@ class TestUserDataRepository : UserDataRepository {
*/
fun getCurrentFollowedTopics(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedTopics
/**
* A test-only API to allow setting of user data directly.
*/
fun setUserData(userData: UserData) {
_userData.tryEmit(userData)
}
}

@ -17,10 +17,12 @@
package com.google.samples.apps.nowinandroid.core.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewFollowableTopics
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import org.junit.Rule
import org.junit.Test
@ -30,12 +32,12 @@ class NewsResourceCardTest {
@Test
fun testMetaDataDisplay_withCodelabResource() {
val newsWithKnownResourceType = previewNewsResources[0]
val newsWithKnownResourceType = previewUserNewsResources[0]
var dateFormatted = ""
composeTestRule.setContent {
NewsResourceCardExpanded(
newsResource = newsWithKnownResourceType,
userNewsResource = newsWithKnownResourceType,
isBookmarked = false,
onToggleBookmark = {},
onClick = {}
@ -57,12 +59,12 @@ class NewsResourceCardTest {
@Test
fun testMetaDataDisplay_withUnknownResource() {
val newsWithUnknownResourceType = previewNewsResources[3]
val newsWithUnknownResourceType = previewUserNewsResources[3]
var dateFormatted = ""
composeTestRule.setContent {
NewsResourceCardExpanded(
newsResource = newsWithUnknownResourceType,
userNewsResource = newsWithUnknownResourceType,
isBookmarked = false,
onToggleBookmark = {},
onClick = {}
@ -75,4 +77,23 @@ class NewsResourceCardTest {
.onNodeWithText(dateFormatted)
.assertIsDisplayed()
}
@Test
fun testTopicsChipColorBackground_matchesFollowedState() {
composeTestRule.setContent {
NewsResourceTopics(topics = previewFollowableTopics)
}
for (followableTopic in previewFollowableTopics) {
val topicName = followableTopic.topic.name
val expectedContentDescription = if (followableTopic.isFollowed) {
"$topicName is followed"
} else {
"$topicName is not followed"
}
composeTestRule
.onNodeWithText(topicName.uppercase())
.assertContentDescriptionEquals(expectedContentDescription)
}
}
}

@ -37,8 +37,8 @@ import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
/**
* An extension on [LazyListScope] defining a feed with news resources.
@ -51,21 +51,21 @@ fun LazyGridScope.newsFeed(
when (feedState) {
NewsFeedUiState.Loading -> Unit
is NewsFeedUiState.Success -> {
items(feedState.feed, key = { it.newsResource.id }) { saveableNewsResource ->
items(feedState.feed, key = { it.id }) { userNewsResource ->
val resourceUrl by remember {
mutableStateOf(Uri.parse(saveableNewsResource.newsResource.url))
mutableStateOf(Uri.parse(userNewsResource.url))
}
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
NewsResourceCardExpanded(
newsResource = saveableNewsResource.newsResource,
isBookmarked = saveableNewsResource.isSaved,
userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved,
onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) },
onToggleBookmark = {
onNewsResourcesCheckedChanged(
saveableNewsResource.newsResource.id,
!saveableNewsResource.isSaved
userNewsResource.id,
!userNewsResource.isSaved
)
}
)
@ -100,7 +100,7 @@ sealed interface NewsFeedUiState {
/**
* The list of news resources contained in this feed.
*/
val feed: List<SaveableNewsResource>
val feed: List<UserNewsResource>
) : NewsFeedUiState
}
@ -125,12 +125,7 @@ private fun NewsFeedContentPreview() {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed(
feedState = NewsFeedUiState.Success(
previewNewsResources.map {
SaveableNewsResource(
it,
false
)
}
previewUserNewsResources
),
onNewsResourcesCheckedChanged = { _, _ -> }
)

@ -46,6 +46,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
@ -56,10 +57,11 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
@ -73,7 +75,7 @@ import kotlinx.datetime.toJavaInstant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsResourceCardExpanded(
newsResource: NewsResource,
userNewsResource: UserNewsResource,
isBookmarked: Boolean,
onToggleBookmark: () -> Unit,
onClick: () -> Unit,
@ -91,9 +93,9 @@ fun NewsResourceCardExpanded(
}
) {
Column {
if (!newsResource.headerImageUrl.isNullOrEmpty()) {
if (!userNewsResource.headerImageUrl.isNullOrEmpty()) {
Row {
NewsResourceHeaderImage(newsResource.headerImageUrl)
NewsResourceHeaderImage(userNewsResource.headerImageUrl)
}
}
Box(
@ -103,18 +105,18 @@ fun NewsResourceCardExpanded(
Spacer(modifier = Modifier.height(12.dp))
Row {
NewsResourceTitle(
newsResource.title,
userNewsResource.title,
modifier = Modifier.fillMaxWidth((.8f))
)
Spacer(modifier = Modifier.weight(1f))
BookmarkButton(isBookmarked, onToggleBookmark)
}
Spacer(modifier = Modifier.height(12.dp))
NewsResourceMetaData(newsResource.publishDate, newsResource.type)
NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type)
Spacer(modifier = Modifier.height(12.dp))
NewsResourceShortDescription(newsResource.content)
NewsResourceShortDescription(userNewsResource.content)
Spacer(modifier = Modifier.height(12.dp))
NewsResourceTopics(newsResource.topics)
NewsResourceTopics(userNewsResource.followableTopics)
}
}
}
@ -228,7 +230,7 @@ fun NewsResourceShortDescription(
@Composable
fun NewsResourceTopics(
topics: List<Topic>,
topics: List<FollowableTopic>,
modifier: Modifier = Modifier
) {
// Store the ID of the Topic which has its "following" menu expanded, if any.
@ -239,17 +241,35 @@ fun NewsResourceTopics(
modifier = modifier.horizontalScroll(rememberScrollState()), // causes narrow chips
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
for (topic in topics) {
for (followableTopic in topics) {
NiaTopicTag(
expanded = expandedTopicId == topic.id,
followed = true, // ToDo: Check if topic is followed
expanded = expandedTopicId == followableTopic.topic.id,
followed = followableTopic.isFollowed,
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) topic.id else null
expandedTopicId = if (show) followableTopic.topic.id else null
},
onFollowClick = { }, // ToDo
onUnfollowClick = { }, // ToDo
onBrowseClick = { }, // ToDo
text = { Text(text = topic.name.uppercase(Locale.getDefault())) }
text = {
val contentDescription = if (followableTopic.isFollowed) {
stringResource(
R.string.topic_chip_content_description_when_followed,
followableTopic.topic.name
)
} else {
stringResource(
R.string.topic_chip_content_description_when_not_followed,
followableTopic.topic.name
)
}
Text(
text = followableTopic.topic.name.uppercase(Locale.getDefault()),
modifier = Modifier.semantics {
this.contentDescription = contentDescription
}
)
}
)
}
}
@ -281,7 +301,7 @@ private fun ExpandedNewsResourcePreview() {
NiaTheme {
Surface {
NewsResourceCardExpanded(
newsResource = previewNewsResources[0],
userNewsResource = previewUserNewsResources[0],
isBookmarked = true,
onToggleBookmark = {},
onClick = {}

@ -23,42 +23,37 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
/**
* Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a generic
* [List] [T].
* Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a list of
* [UserNewsResource]s.
*
* [newsResourceMapper] maps type [T] to a [NewsResource]
* [isBookmarkedMapper] maps type [T] to whether the [NewsResource] is bookmarked
* [onToggleBookmark] defines the action invoked when a user wishes to bookmark an item
* [onItemClick] optional parameter for action to be performed when the card is clicked. The
* default action launches an intent matching the card.
*/
fun <T> LazyListScope.newsResourceCardItems(
items: List<T>,
newsResourceMapper: (item: T) -> NewsResource,
isBookmarkedMapper: (item: T) -> Boolean,
onToggleBookmark: (item: T) -> Unit,
onItemClick: ((item: T) -> Unit)? = null,
fun LazyListScope.userNewsResourceCardItems(
items: List<UserNewsResource>,
onToggleBookmark: (item: UserNewsResource) -> Unit,
onItemClick: ((item: UserNewsResource) -> Unit)? = null,
itemModifier: Modifier = Modifier,
) = items(
items = items,
key = { newsResourceMapper(it).id },
itemContent = { item ->
val newsResource = newsResourceMapper(item)
val resourceUrl = Uri.parse(newsResource.url)
key = { it.id },
itemContent = { userNewsResource ->
val resourceUrl = Uri.parse(userNewsResource.url)
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val context = LocalContext.current
NewsResourceCardExpanded(
newsResource = newsResource,
isBookmarked = isBookmarkedMapper(item),
onToggleBookmark = { onToggleBookmark(item) },
userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved,
onToggleBookmark = { onToggleBookmark(userNewsResource) },
onClick = {
when (onItemClick) {
null -> launchCustomChromeTab(context, resourceUrl, backgroundColor)
else -> onItemClick(item)
else -> onItemClick(userNewsResource)
}
},
modifier = itemModifier

@ -21,4 +21,7 @@
<string name="card_tap_action">Open Resource Link</string>
<string name="card_meta_data_text">%1$s • %2$s</string>
<string name="topic_chip_content_description_when_followed">%1$s is followed</string>
<string name="topic_chip_content_description_when_not_followed">%1$s is not followed</string>
</resources>

@ -30,8 +30,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@ -67,8 +66,7 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
previewUserNewsResources.take(2)
),
removeFromBookmarks = { }
)
@ -76,7 +74,7 @@ class BookmarksScreenTest {
composeTestRule
.onNodeWithText(
previewNewsResources[0].title,
previewUserNewsResources[0].title,
substring = true
)
.assertExists()
@ -85,14 +83,14 @@ class BookmarksScreenTest {
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
.assertExists()
@ -106,11 +104,10 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
previewUserNewsResources.take(2)
),
removeFromBookmarks = { newsResourceId ->
assertEquals(previewNewsResources[0].id, newsResourceId)
assertEquals(previewUserNewsResources[0].id, newsResourceId)
removeFromBookmarksCalled = true
}
)
@ -124,7 +121,7 @@ class BookmarksScreenTest {
).filter(
hasAnyAncestor(
hasText(
previewNewsResources[0].title,
previewUserNewsResources[0].title,
substring = true
)
)

@ -52,8 +52,7 @@ import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
@ -184,9 +183,7 @@ private fun BookmarksGridPreview() {
NiaTheme {
BookmarksGrid(
feedState = Success(
previewNewsResources.map {
SaveableNewsResource(it, false)
}
previewUserNewsResources
),
removeFromBookmarks = {}
)

@ -19,8 +19,8 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import dagger.hilt.android.lifecycle.HiltViewModel
@ -36,13 +36,13 @@ import kotlinx.coroutines.launch
@HiltViewModel
class BookmarksViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
getSaveableNewsResources: GetSaveableNewsResourcesUseCase
getSaveableNewsResources: GetUserNewsResourcesUseCase
) : ViewModel() {
val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResources()
.filterNot { it.isEmpty() }
.map { newsResources -> newsResources.filter(SaveableNewsResource::isSaved) } // Only show bookmarked news resources.
.map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources.
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(Loading) }
.stateIn(
scope = viewModelScope,

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -43,7 +43,7 @@ class BookmarksViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
@ -53,7 +53,7 @@ class BookmarksViewModelTest {
fun setup() {
viewModel = BookmarksViewModel(
userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase
getSaveableNewsResources = getUserNewsResourcesUseCase
)
}

@ -29,9 +29,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import org.junit.Rule
import org.junit.Test
@ -225,9 +224,7 @@ class ForYouScreenTest {
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -237,7 +234,7 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithText(
previewNewsResources[0].title,
previewUserNewsResources[0].title,
substring = true
)
.assertExists()
@ -246,14 +243,14 @@ class ForYouScreenTest {
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
.assertExists()

@ -83,8 +83,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverl
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
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.NewsFeedUiState
@ -396,9 +395,7 @@ fun ForYouScreenPopulatedFeed() {
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -417,9 +414,7 @@ fun ForYouScreenOfflinePopulatedFeed() {
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -440,9 +435,7 @@ fun ForYouScreenTopicSelection() {
topics = previewTopics.map { FollowableTopic(it, false) },
),
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -478,9 +471,7 @@ fun ForYouScreenPopulatedAndLoading() {
isSyncing = true,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},

@ -21,8 +21,8 @@ import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository,
private val getSaveableNewsResources: GetSaveableNewsResourcesUseCase,
private val getSaveableNewsResources: GetUserNewsResourcesUseCase,
getFollowableTopics: GetFollowableTopicsUseCase
) : ViewModel() {
@ -117,6 +117,6 @@ class ForYouViewModel @Inject constructor(
}
}
private fun Flow<List<SaveableNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
private fun Flow<List<UserNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(NewsFeedUiState.Loading) }

@ -17,15 +17,17 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
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.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.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor
@ -54,7 +56,7 @@ class ForYouViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
@ -69,7 +71,7 @@ class ForYouViewModelTest {
viewModel = ForYouViewModel(
syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase,
getSaveableNewsResources = getUserNewsResourcesUseCase,
getFollowableTopics = getFollowableTopicsUseCase
)
}
@ -263,7 +265,10 @@ class ForYouViewModelTest {
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("0", "1"))
val followedTopicIds = setOf("0", "1")
val userData = emptyUserData.copy(followedTopics = followedTopicIds)
userDataRepository.setUserData(userData)
viewModel.dismissOnboarding()
assertEquals(
@ -280,13 +285,7 @@ class ForYouViewModelTest {
)
assertEquals(
NewsFeedUiState.Success(
feed =
sampleNewsResources.map {
SaveableNewsResource(
newsResource = it,
isSaved = false
)
}
feed = sampleNewsResources.mapToUserNewsResources(userData)
),
viewModel.feedState.value
)
@ -307,41 +306,9 @@ class ForYouViewModelTest {
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
topics = sampleTopics.map {
FollowableTopic(it, false)
}
),
viewModel.onboardingUiState.value
)
@ -352,59 +319,25 @@ class ForYouViewModelTest {
viewModel.feedState.value
)
viewModel.updateTopicSelection("1", isChecked = true)
val followedTopicId = sampleTopics[1].id
viewModel.updateTopicSelection(followedTopicId, isChecked = true)
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = true
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
topics = sampleTopics.map {
FollowableTopic(it, it.id == followedTopicId)
}
),
viewModel.onboardingUiState.value
)
val userData = emptyUserData.copy(followedTopics = setOf(followedTopicId))
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
UserNewsResource(sampleNewsResources[1], userData),
UserNewsResource(sampleNewsResources[2], userData),
)
),
viewModel.feedState.value
@ -484,11 +417,25 @@ class ForYouViewModelTest {
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val followedTopicIds = setOf("1")
val userData = emptyUserData.copy(
followedTopics = followedTopicIds,
shouldHideOnboarding = true
)
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("1"))
userDataRepository.setShouldHideOnboarding(true)
userDataRepository.setUserData(userData)
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved("2", true)
val bookmarkedNewsResourceId = "2"
viewModel.updateNewsResourceSaved(
newsResourceId = bookmarkedNewsResourceId,
isChecked = true
)
val userDataExpected = userData.copy(
bookmarkedNewsResources = setOf(bookmarkedNewsResourceId)
)
assertEquals(
OnboardingUiState.NotShown,
@ -497,14 +444,8 @@ class ForYouViewModelTest {
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = true
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
UserNewsResource(newsResource = sampleNewsResources[1], userDataExpected),
UserNewsResource(newsResource = sampleNewsResources[2], userDataExpected)
)
),
viewModel.feedState.value

@ -25,10 +25,11 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
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.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
@ -99,14 +100,7 @@ class TopicScreenTest {
composeTestRule.setContent {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
newsUiState = NewsUiState.Success(sampleUserNewsResources),
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
@ -126,12 +120,7 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Success(testTopic),
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
sampleUserNewsResources
),
onBackClick = { },
onFollowClick = { },
@ -143,7 +132,7 @@ class TopicScreenTest {
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(hasText(sampleNewsResources.first().title))
.performScrollToNode(hasText(sampleUserNewsResources.first().title))
}
}
@ -188,27 +177,31 @@ private val testTopics = listOf(
)
)
private val sampleNewsResources = listOf(
NewsResource(
id = "1",
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",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
private val sampleUserNewsResources = listOf(
UserNewsResource(
newsResource =
NewsResource(
id = "1",
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",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
)
)
)
),
userData = emptyUserData.copy(bookmarkedNewsResources = setOf("1"))
)
)

@ -53,12 +53,11 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadi
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
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.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@ -146,7 +145,7 @@ private fun LazyListScope.TopicBody(
TopicHeader(name, description, imageUrl)
}
TopicCards(news, onBookmarkChanged)
userNewsResourceCards(news, onBookmarkChanged)
}
@Composable
@ -174,17 +173,16 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) {
}
}
private fun LazyListScope.TopicCards(
// TODO: Could/should this be replaced with [LazyGridScope.newsFeed]?
private fun LazyListScope.userNewsResourceCards(
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit
) {
when (news) {
is NewsUiState.Success -> {
newsResourceCardItems(
userNewsResourceCardItems(
items = news.news,
newsResourceMapper = { it.newsResource },
isBookmarkedMapper = { it.isSaved },
onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) },
onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) },
itemModifier = Modifier.padding(24.dp)
)
}
@ -257,12 +255,7 @@ fun TopicScreenPopulated() {
TopicScreen(
topicUiState = TopicUiState.Success(FollowableTopic(previewTopics[0], false)),
newsUiState = NewsUiState.Success(
previewNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
previewUserNewsResources
),
onBackClick = {},
onFollowClick = {},

@ -22,9 +22,9 @@ import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
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
@ -45,7 +45,7 @@ class TopicViewModel @Inject constructor(
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
getSaveableNewsResources: GetSaveableNewsResourcesUseCase
getSaveableNewsResources: GetUserNewsResourcesUseCase
) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder)
@ -130,11 +130,11 @@ private fun topicUiState(
private fun newsUiState(
topicId: String,
getSaveableNewsResources: GetSaveableNewsResourcesUseCase,
getSaveableNewsResources: GetUserNewsResourcesUseCase,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> {
// Observe news
val newsStream: Flow<List<SaveableNewsResource>> = getSaveableNewsResources(
val newsStream: Flow<List<UserNewsResource>> = getSaveableNewsResources(
filterTopicIds = setOf(element = topicId),
)
@ -171,7 +171,7 @@ sealed interface TopicUiState {
}
sealed interface NewsUiState {
data class Success(val news: List<SaveableNewsResource>) : NewsUiState
data class Success(val news: List<UserNewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
@ -53,7 +53,7 @@ class TopicViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
@ -66,7 +66,7 @@ class TopicViewModelTest {
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase
getSaveableNewsResources = getUserNewsResourcesUseCase
)
}

Loading…
Cancel
Save