Add People carousel

Screenshot: https://screenshot.googleplex.com/9K6C4NZMfMzCABE.png

Change-Id: I32b0240910df6a953c8843895f3b7e22d5adc5de
pull/2/head
Manuel Vivo 2 years ago committed by Don Turner
parent ca73f5598f
commit de2f07d1a4

@ -20,7 +20,9 @@ import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiADatabase
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -38,6 +40,7 @@ class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao
private lateinit var episodeDao: EpisodeDao
private lateinit var topicDao: TopicDao
private lateinit var authorDao: AuthorDao
private lateinit var db: NiADatabase
@Before
@ -50,6 +53,7 @@ class NewsResourceDaoTest {
newsResourceDao = db.newsResourceDao()
episodeDao = db.episodeDao()
topicDao = db.topicDao()
authorDao = db.authorDao()
}
@Test
@ -147,12 +151,69 @@ class NewsResourceDaoTest {
newsResourceTopicCrossRefEntities
)
val filteredNewsResources = newsResourceDao.getNewsResourcesStream(
val filteredNewsResources = newsResourceDao.getNewsResourcesForTopicsStream(
filterTopicIds = topicEntities
.map(TopicEntity::id)
.toSet()
).first()
assertEquals(
listOf(1, 2),
filteredNewsResources.map { it.entity.id }
)
}
@Test
fun newsResourceDao_filters_items_by_author_topics_ids_by_descending_publish_date() = runTest {
val authorEntities = listOf(
testAuthorEntity(
id = 1,
name = "1"
),
testAuthorEntity(
id = 2,
name = "2"
),
)
val newsResourceEntities = listOf(
testNewsResource(
id = 0,
millisSinceEpoch = 0,
),
testNewsResource(
id = 1,
millisSinceEpoch = 3,
),
testNewsResource(
id = 2,
millisSinceEpoch = 1,
),
testNewsResource(
id = 3,
millisSinceEpoch = 2,
),
)
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
val newsResourceAuthorCrossRefEntities = authorEntities.mapIndexed { index, authorEntity ->
NewsResourceAuthorCrossRef(
newsResourceId = index,
authorId = authorEntity.id
)
}
authorDao.upsertAuthors(authorEntities)
episodeDao.upsertEpisodes(episodeEntityShells)
newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities)
val filteredNewsResources = newsResourceDao.getNewsResourcesForAuthorsStream(
filterAuthorIds = authorEntities
.map(AuthorEntity::id)
.toSet()
).first()
assertEquals(
listOf(1, 0),
filteredNewsResources.map { it.entity.id }
@ -160,6 +221,17 @@ class NewsResourceDaoTest {
}
}
private fun testAuthorEntity(
id: Int = 0,
name: String
) = AuthorEntity(
id = id,
name = name,
imageUrl = "",
twitter = "",
mediumPage = ""
)
private fun testTopicEntity(
id: Int = 0,
name: String

@ -53,7 +53,43 @@ interface NewsResourceDao {
ORDER BY publish_date DESC
"""
)
fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<PopulatedNewsResource>>
fun getNewsResourcesForTopicsStream(
filterTopicIds: Set<Int>
): Flow<List<PopulatedNewsResource>>
@Query(
value = """
SELECT * FROM news_resources
WHERE id in
(
SELECT news_resource_id FROM news_resources_authors
WHERE author_id IN (:filterAuthorIds)
)
ORDER BY publish_date DESC
"""
)
fun getNewsResourcesForAuthorsStream(
filterAuthorIds: Set<Int>
): Flow<List<PopulatedNewsResource>>
@Query(
value = """
SELECT * FROM news_resources
WHERE id in
(
SELECT topics.topic_id FROM news_resources_topics as topics
INNER JOIN news_resources_authors as authors
ON topics.news_resource_id == authors.news_resource_id
WHERE topics.topic_id IN (:filterTopicIds)
AND authors.author_id IN (:filterAuthorIds)
)
ORDER BY publish_date DESC
"""
)
fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int>
): Flow<List<PopulatedNewsResource>>
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do

@ -104,4 +104,43 @@ class NiaPreferences @Inject constructor(
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<Int>) {
try {
userPreferences.updateData {
it.copy {
this.followedAuthorIds.clear()
this.followedAuthorIds.addAll(followedAuthorIds)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) {
try {
userPreferences.updateData {
it.copy {
val current =
if (followed) {
followedAuthorIds + followedAuthorId
} else {
followedAuthorIds - followedAuthorId
}
this.followedAuthorIds.clear()
this.followedAuthorIds.addAll(current)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
val followedAuthorIds: Flow<Set<Int>> = userPreferences.data
.retry {
Log.e("NiaPreferences", "Failed to read user preferences", it)
true
}
.map { it.followedAuthorIdsList.toSet() }
}

@ -26,4 +26,5 @@ message UserPreferences {
int32 authorChangeListVersion = 4;
int32 episodeChangeListVersion = 5;
int32 newsResourceChangeListVersion = 6;
repeated int32 followed_author_ids = 7;
}

@ -21,6 +21,8 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
/**
* Attempts [block], returning a successful [Result] if it succeeds, otherwise a [Result.Failure]
@ -71,3 +73,29 @@ suspend fun changeListSync(
versionUpdater(latestVersion)
}
}.isSuccess
/**
* Returns a [Flow] whose values are generated by [transform] function that process the most
* recently emitted values by each flow.
*/
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
): Flow<R> = combine(
combine(flow, flow2, flow3, ::Triple),
combine(flow4, flow5, flow6, ::Triple)
) { t1, t2 ->
transform(
t1.first,
t1.second,
t1.third,
t2.first,
t2.second,
t2.third
)
}

@ -25,6 +25,21 @@ interface AuthorsRepository {
*/
fun getAuthorsStream(): Flow<List<Author>>
/**
* Sets the user's currently followed authors
*/
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<Int>)
/**
* Toggles the user's newly followed/unfollowed author
*/
suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean)
/**
* Returns the users currently followed authors
*/
fun getFollowedAuthorIdsStream(): Flow<Set<Int>>
/**
* Synchronizes the local database in backing the repository with the network.
* Returns if the sync was successful or not.

@ -43,6 +43,14 @@ class LocalAuthorsRepository @Inject constructor(
authorDao.getAuthorEntitiesStream()
.map { it.map(AuthorEntity::asExternalModel) }
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<Int>) =
niaPreferences.setFollowedAuthorIds(followedAuthorIds)
override suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) =
niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed)
override fun getFollowedAuthorIdsStream(): Flow<Set<Int>> = niaPreferences.followedAuthorIds
override suspend fun sync(): Boolean = changeListSync(
niaPreferences = niaPreferences,
versionReader = ChangeListVersions::authorVersion,

@ -57,9 +57,21 @@ class LocalNewsRepository @Inject constructor(
newsResourceDao.getNewsResourcesStream()
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>> =
newsResourceDao.getNewsResourcesStream(filterTopicIds = filterTopicIds)
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int>
): Flow<List<NewsResource>> = when {
filterAuthorIds.isEmpty() -> {
newsResourceDao.getNewsResourcesForTopicsStream(filterTopicIds)
}
filterTopicIds.isEmpty() -> {
newsResourceDao.getNewsResourcesForAuthorsStream(filterAuthorIds)
}
else -> {
newsResourceDao.getNewsResourcesStream(filterAuthorIds, filterTopicIds)
}
}
.map { it.map(PopulatedNewsResource::asExternalModel) }
override suspend fun sync() = changeListSync(
niaPreferences = niaPreferences,

@ -29,9 +29,12 @@ interface NewsRepository {
fun getNewsResourcesStream(): Flow<List<NewsResource>>
/**
* Returns available news resources as a stream filtered by the topic.
* Returns available news resources as a stream filtered by authors and topics.
*/
fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>>
fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int>,
): Flow<List<NewsResource>>
/**
* Synchronizes the local database in backing the repository with the network.

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.domain.repository.fake
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
@ -31,16 +32,17 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
/**
* Fake implementation of the [AuthorsRepository] that retrieves the Authors from a JSON String, and
* uses a local DataStore instance to save and retrieve followed Author ids.
* Fake implementation of the [AuthorsRepository] that returns hardcoded authors.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeAuthorsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val niaPreferences: NiaPreferences,
private val networkJson: Json,
) : AuthorsRepository {
override fun getAuthorsStream(): Flow<List<Author>> = flow {
emit(
networkJson.decodeFromString<List<NetworkAuthor>>(FakeDataSource.authors).map {
@ -56,5 +58,15 @@ class FakeAuthorsRepository @Inject constructor(
}
.flowOn(ioDispatcher)
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<Int>) {
niaPreferences.setFollowedAuthorIds(followedAuthorIds)
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) {
niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed)
}
override fun getFollowedAuthorIdsStream(): Flow<Set<Int>> = niaPreferences.followedAuthorIds
override suspend fun sync() = true
}

@ -54,11 +54,17 @@ class FakeNewsRepository @Inject constructor(
}
.flowOn(ioDispatcher)
override fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>> =
override fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int>,
): Flow<List<NewsResource>> =
flow {
emit(
networkJson.decodeFromString<List<NetworkNewsResource>>(FakeDataSource.data)
.filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
.filter {
it.authors.intersect(filterAuthorIds).isNotEmpty() ||
it.topics.intersect(filterTopicIds).isNotEmpty()
}
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
)

@ -36,8 +36,8 @@ import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestEpisodeD
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestTopicDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.filteredTopicIds
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.nonPresentTopicIds
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.filteredInterestsIds
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.nonPresentInterestsIds
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import kotlinx.coroutines.flow.first
@ -101,19 +101,49 @@ class LocalNewsRepositoryTest {
}
@Test
fun localNewsRepository_news_resources_filtered_stream_is_backed_by_news_resource_dao() =
fun localNewsRepository_news_resources_topic_filtered_stream_is_backed_by_news_resource_dao() =
runTest {
assertEquals(
newsResourceDao.getNewsResourcesStream(filterTopicIds = filteredTopicIds)
newsResourceDao.getNewsResourcesForTopicsStream(filteredInterestsIds)
.first()
.map(PopulatedNewsResource::asExternalModel),
subject.getNewsResourcesStream(filterTopicIds = filteredTopicIds)
subject.getNewsResourcesStream(
filterTopicIds = filteredInterestsIds,
filterAuthorIds = emptySet()
)
.first()
)
assertEquals(
emptyList<NewsResource>(),
subject.getNewsResourcesStream(filterTopicIds = nonPresentTopicIds)
subject.getNewsResourcesStream(
filterTopicIds = nonPresentInterestsIds,
filterAuthorIds = emptySet()
)
.first()
)
}
@Test
fun localNewsRepository_news_resources_author_filtered_stream_is_backed_by_news_resource_dao() =
runTest {
assertEquals(
newsResourceDao.getNewsResourcesForAuthorsStream(filteredInterestsIds)
.first()
.map(PopulatedNewsResource::asExternalModel),
subject.getNewsResourcesStream(
filterTopicIds = emptySet(),
filterAuthorIds = filteredInterestsIds
)
.first()
)
assertEquals(
emptyList<NewsResource>(),
subject.getNewsResourcesStream(
filterTopicIds = emptySet(),
filterAuthorIds = nonPresentInterestsIds
)
.first()
)
}

@ -30,8 +30,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Instant
val filteredTopicIds = setOf(1)
val nonPresentTopicIds = setOf(2)
val filteredInterestsIds = setOf(1)
val nonPresentInterestsIds = setOf(2)
/**
* Test double for [NewsResourceDao]
@ -63,15 +63,33 @@ class TestNewsResourceDao : NewsResourceDao {
}
override fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int>
): Flow<List<PopulatedNewsResource>> =
getNewsResourcesStream()
.map { resources ->
resources.filter { resource ->
resource.topics.any { it.id in filterTopicIds }
resource.topics.any { it.id in filterTopicIds } ||
resource.authors.any { it.id in filterAuthorIds }
}
}
override fun getNewsResourcesForTopicsStream(
filterTopicIds: Set<Int>
): Flow<List<PopulatedNewsResource>> =
getNewsResourcesStream()
.map { resources ->
resources.filter { resource -> resource.topics.any { it.id in filterTopicIds } }
}
override fun getNewsResourcesForAuthorsStream(
filterAuthorIds: Set<Int>
): Flow<List<PopulatedNewsResource>> =
getNewsResourcesStream()
.map { resources ->
resources.filter { resource -> resource.authors.any { it.id in filterAuthorIds } }
}
override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>
): List<Long> {
@ -108,7 +126,7 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
),
authors = listOf(
AuthorEntity(
id = 2,
id = this.episodeId,
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
@ -117,7 +135,7 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
),
topics = listOf(
TopicEntity(
id = filteredTopicIds.random(),
id = filteredInterestsIds.random(),
name = "name",
shortDescription = "short description",
longDescription = "long description",

@ -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
/**
* An [author] with the additional information for whether or not it is followed.
*/
data class FollowableAuthor(
val author: Author,
val isFollowed: Boolean
)

@ -0,0 +1,68 @@
/*
* 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.testing.repository
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Author
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
class TestAuthorsRepository : AuthorsRepository {
/**
* The backing hot flow for the list of followed author ids for testing.
*/
private val _followedAuthorIds: MutableSharedFlow<Set<Int>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/**
* The backing hot flow for the list of author ids for testing.
*/
private val authorsFlow: MutableSharedFlow<List<Author>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override fun getAuthorsStream(): Flow<List<Author>> = authorsFlow
override fun getFollowedAuthorIdsStream(): Flow<Set<Int>> = _followedAuthorIds
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<Int>) {
_followedAuthorIds.tryEmit(followedAuthorIds)
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) {
getCurrentFollowedAuthors()?.let { current ->
_followedAuthorIds.tryEmit(
if (followed) current.plus(followedAuthorId)
else current.minus(followedAuthorId)
)
}
}
override suspend fun sync(): Boolean = true
/**
* A test-only API to allow controlling the list of topics from tests.
*/
fun sendAuthors(authors: List<Author>) {
authorsFlow.tryEmit(authors)
}
/**
* A test-only API to allow querying the current followed topics.
*/
fun getCurrentFollowedAuthors(): Set<Int>? = _followedAuthorIds.replayCache.firstOrNull()
}

@ -17,6 +17,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.Author
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
@ -35,10 +36,15 @@ class TestNewsRepository : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> = newsResourcesFlow
override fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int>
): Flow<List<NewsResource>> =
getNewsResourcesStream().map { newsResources ->
newsResources.filter { it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty() }
newsResources
.filter {
it.authors.map(Author::id).intersect(filterAuthorIds).isNotEmpty() ||
it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
}
}
/**

@ -0,0 +1,106 @@
/*
* 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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun FollowButton(
following: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onFollowChange: ((Boolean) -> Unit)? = null,
backgroundColor: Color = Color.Transparent,
size: Dp = 32.dp,
iconSize: Dp = size / 2,
followingContentDescription: String? = null,
notFollowingContentDescription: String? = null,
) {
val background = if (following) {
MaterialTheme.colorScheme.secondaryContainer
} else {
backgroundColor
}
Box(
modifier = modifier.followButton(onFollowChange, following, enabled, background, size),
contentAlignment = Alignment.Center
) {
if (following) {
Icon(
imageVector = Filled.Done,
contentDescription = followingContentDescription,
modifier = Modifier.size(iconSize)
)
} else {
Icon(
imageVector = Filled.Add,
contentDescription = notFollowingContentDescription,
modifier = Modifier.size(iconSize)
)
}
}
}
private fun Modifier.followButton(
onFollowChange: ((Boolean) -> Unit)?,
following: Boolean,
enabled: Boolean,
background: Color,
size: Dp
): Modifier = composed {
val boxModifier = if (onFollowChange != null) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = rememberRipple(bounded = false, radius = 24.dp)
this
.toggleable(
value = following,
onValueChange = onFollowChange,
enabled = enabled,
role = Role.Checkbox,
interactionSource = interactionSource,
indication = ripple
)
} else {
this
}
boxModifier
.clip(CircleShape)
.background(background)
.size(size)
}

@ -16,24 +16,18 @@
package com.google.samples.apps.nowinandroid.feature.following
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconToggleButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -48,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
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.FollowButton
import com.google.samples.apps.nowinandroid.core.ui.NiaLoadingIndicator
import com.google.samples.apps.nowinandroid.core.ui.NiaToolbar
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
@ -131,7 +126,7 @@ fun FollowingTopicCard(
onFollowButtonClick: (Int, Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.Top,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.padding(
start = 24.dp,
@ -154,9 +149,16 @@ fun FollowingTopicCard(
TopicDescription(topicDescription = followableTopic.topic.shortDescription)
}
FollowButton(
topicId = followableTopic.topic.id,
onClick = onFollowButtonClick,
isFollowed = followableTopic.isFollowed
following = followableTopic.isFollowed,
onFollowChange = { following ->
onFollowButtonClick(followableTopic.topic.id, following)
},
notFollowingContentDescription = stringResource(
id = R.string.following_topic_card_follow_button_content_desc
),
followingContentDescription = stringResource(
id = R.string.following_topic_card_unfollow_button_content_desc
)
)
}
}
@ -209,50 +211,6 @@ fun TopicIcon(
}
}
@Composable
fun FollowButton(
topicId: Int,
isFollowed: Boolean,
onClick: (Int, Boolean) -> Unit,
) {
IconToggleButton(
checked = isFollowed,
onCheckedChange = { onClick(topicId, !isFollowed) }
) {
if (isFollowed) {
FollowedTopicIcon()
} else {
Icon(
imageVector = Icons.Filled.Add,
contentDescription =
stringResource(id = R.string.following_topic_card_follow_button_content_desc),
modifier = Modifier.size(14.dp)
)
}
}
}
@Composable
fun FollowedTopicIcon() {
Box(
modifier = Modifier
.size(30.dp)
.background(
color = Color.Magenta.copy(alpha = 0.5f),
shape = CircleShape
)
) {
Icon(
imageVector = Icons.Filled.Done,
contentDescription =
stringResource(id = R.string.following_topic_card_unfollow_button_content_desc),
modifier = Modifier
.size(14.dp)
.align(Alignment.Center)
)
}
}
@Preview("Topic card")
@Composable
fun TopicCardPreview() {

@ -44,6 +44,9 @@ dependencies {
implementation libs.accompanist.flowlayout
implementation libs.coil.kt
implementation libs.coil.kt.compose
implementation libs.hilt.android
kapt libs.hilt.compiler
@ -55,4 +58,4 @@ dependencies {
force 'org.objenesis:objenesis:2.6'
}
}
}
}

@ -21,9 +21,13 @@ import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import org.junit.Rule
@ -38,6 +42,7 @@ class ForYouScreenTest {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -55,7 +60,7 @@ class ForYouScreenTest {
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -63,8 +68,8 @@ class ForYouScreenTest {
name = "Headlines",
shortDescription = "",
longDescription = "",
imageUrl = "",
url = ""
url = "",
imageUrl = ""
),
isFollowed = false
),
@ -74,8 +79,8 @@ class ForYouScreenTest {
name = "UI",
shortDescription = "",
longDescription = "",
imageUrl = "",
url = ""
url = "",
imageUrl = ""
),
isFollowed = false
),
@ -85,14 +90,37 @@ class ForYouScreenTest {
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
url = ""
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
),
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -125,7 +153,7 @@ class ForYouScreenTest {
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -133,8 +161,8 @@ class ForYouScreenTest {
name = "Headlines",
shortDescription = "",
longDescription = "",
imageUrl = "",
url = ""
url = "",
imageUrl = ""
),
isFollowed = false
),
@ -144,8 +172,8 @@ class ForYouScreenTest {
name = "UI",
shortDescription = "",
longDescription = "",
imageUrl = "",
url = ""
url = "",
imageUrl = ""
),
isFollowed = true
),
@ -155,14 +183,37 @@ class ForYouScreenTest {
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
url = ""
twitter = "",
mediumPage = ""
),
isFollowed = false
),
),
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -184,6 +235,117 @@ class ForYouScreenTest {
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Android Dev")
.assertIsDisplayed()
.assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText(composeTestRule.activity.resources.getString(R.string.done))
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
}
@Test
fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
id = 0,
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 1,
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = 2,
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = true
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
),
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
composeTestRule
.onNodeWithText("Headlines")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule
.onNodeWithText("UI")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Tools")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Android Dev")
.assertIsDisplayed()
.assertIsOn()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Android Dev 2")
.assertIsDisplayed()
.assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText(composeTestRule.activity.resources.getString(R.string.done))
.assertIsDisplayed()

@ -0,0 +1,165 @@
/*
* 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.feature.foryou
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.ui.FollowButton
@Composable
fun AuthorsCarousel(
authors: List<FollowableAuthor>,
onAuthorClick: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(modifier) {
items(items = authors, key = { item -> item.author.id }) { followableAuthor ->
AuthorItem(
author = followableAuthor.author,
following = followableAuthor.isFollowed,
onAuthorClick = { following ->
onAuthorClick(followableAuthor.author.id, following)
},
modifier = Modifier.padding(8.dp)
)
}
}
}
@Composable
fun AuthorItem(
author: Author,
following: Boolean,
onAuthorClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val followDescription = if (following) {
stringResource(id = R.string.following)
} else {
stringResource(id = R.string.not_following)
}
Column(
modifier = modifier
.toggleable(
value = following,
enabled = true,
role = Role.Button,
onValueChange = { newFollowing -> onAuthorClick(newFollowing) },
)
.sizeIn(maxWidth = 48.dp)
.semantics(mergeDescendants = true) {
stateDescription = "$followDescription ${author.name}"
}
) {
Box(modifier = Modifier.fillMaxWidth()) {
AsyncImage(
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
model = author.imageUrl,
contentScale = ContentScale.Fit,
contentDescription = null
)
FollowButton(
following = following,
backgroundColor = MaterialTheme.colorScheme.surface,
size = 20.dp,
iconSize = 14.dp,
modifier = Modifier.align(Alignment.BottomEnd)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = author.name,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium,
maxLines = 2,
modifier = Modifier.fillMaxWidth()
)
}
}
@Preview
@Composable
fun AuthorCarouselPreview() {
MaterialTheme {
Surface {
AuthorsCarousel(
authors = listOf(
FollowableAuthor(
Author(1, "Android Dev", "", "", ""),
false
),
FollowableAuthor(
Author(2, "Android Dev2", "", "", ""),
true
),
FollowableAuthor(
Author(3, "Android Dev3", "", "", ""),
false
)
),
onAuthorClick = { _, _ -> },
)
}
}
}
@Preview
@Composable
fun AuthorItemPreview() {
MaterialTheme {
Surface {
AuthorItem(
author = Author(0, "Android Dev", "", "", ""),
following = true,
onAuthorClick = { }
)
}
}
}

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -37,6 +36,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -54,6 +54,8 @@ import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
@ -65,7 +67,7 @@ import com.google.samples.apps.nowinandroid.core.ui.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTypography
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection
import kotlinx.datetime.Instant
@ -79,7 +81,8 @@ fun ForYouRoute(
modifier = modifier,
uiState = uiState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
saveFollowedTopics = viewModel::saveFollowedTopics,
onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved
)
}
@ -88,6 +91,7 @@ fun ForYouRoute(
fun ForYouScreen(
uiState: ForYouFeedUiState,
onTopicCheckedChanged: (Int, Boolean) -> Unit,
onAuthorCheckedChanged: (Int, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier,
@ -106,9 +110,40 @@ fun ForYouScreen(
}
is PopulatedFeed -> {
when (uiState) {
is FeedWithTopicSelection -> {
is FeedWithInterestsSelection -> {
item {
TopicSelection(uiState, onTopicCheckedChanged)
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = NiaTypography.titleMedium
)
}
item {
Text(
text = stringResource(R.string.onboarding_guidance_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp),
textAlign = TextAlign.Center,
style = NiaTypography.bodyMedium
)
}
item {
AuthorsCarousel(
authors = uiState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier.padding(vertical = 8.dp)
)
}
item {
TopicSelection(
uiState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
}
item {
// Done button
@ -153,54 +188,35 @@ fun ForYouScreen(
@Composable
private fun TopicSelection(
uiState: ForYouFeedUiState,
onTopicCheckedChanged: (Int, Boolean) -> Unit
uiState: FeedWithInterestsSelection,
onTopicCheckedChanged: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Column(Modifier.padding(top = 24.dp)) {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
style = NiaTypography.titleMedium
)
Text(
text = stringResource(R.string.onboarding_guidance_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp),
textAlign = TextAlign.Center,
style = NiaTypography.bodyMedium
)
LazyHorizontalGrid(
rows = Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.dp),
modifier = Modifier
// LazyHorizontalGrid has to be constrained in height.
// However, we can't set a fixed height because the horizontal grid contains
// vertical text that can be rescaled.
// When the fontScale is at most 1, we know that the horizontal grid will be at most
// 240dp tall, so this is an upper bound for when the font scale is at most 1.
// When the fontScale is greater than 1, the height required by the text inside the
// horizontal grid will increase by at most the same factor, so 240sp is a valid
// upper bound for how much space we need in that case.
// The maximum of these two bounds is therefore a valid upper bound in all cases.
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth()
) {
val state: FeedWithTopicSelection = uiState as FeedWithTopicSelection
items(state.topics) {
SingleTopicButton(
name = it.topic.name,
topicId = it.topic.id,
isSelected = it.isFollowed,
onClick = onTopicCheckedChanged
)
}
LazyHorizontalGrid(
rows = Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.dp),
modifier = modifier
// LazyHorizontalGrid has to be constrained in height.
// However, we can't set a fixed height because the horizontal grid contains
// vertical text that can be rescaled.
// When the fontScale is at most 1, we know that the horizontal grid will be at most
// 240dp tall, so this is an upper bound for when the font scale is at most 1.
// When the fontScale is greater than 1, the height required by the text inside the
// horizontal grid will increase by at most the same factor, so 240sp is a valid
// upper bound for how much space we need in that case.
// The maximum of these two bounds is therefore a valid upper bound in all cases.
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth()
) {
items(uiState.topics) {
SingleTopicButton(
name = it.topic.name,
topicId = it.topic.id,
isSelected = it.isFollowed,
onClick = onTopicCheckedChanged
)
}
}
}
@ -230,7 +246,10 @@ private fun SingleTopicButton(
Text(
text = name,
style = NiaTypography.titleSmall,
modifier = Modifier.padding(12.dp).weight(1f),
modifier = Modifier
.padding(12.dp)
.weight(1f),
color = MaterialTheme.colorScheme.onSurface
)
NiaToggleButton(
checked = isSelected,
@ -249,19 +268,24 @@ private fun SingleTopicButton(
@Preview
@Composable
fun ForYouScreenLoading() {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
MaterialTheme {
Surface {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
@Preview
@Composable
fun ForYouScreenTopicSelection() {
ForYouScreen(
uiState = FeedWithTopicSelection(
uiState = FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -376,8 +400,41 @@ fun ForYouScreenTopicSelection() {
),
isSaved = false
),
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
),
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -387,12 +444,17 @@ fun ForYouScreenTopicSelection() {
@Preview
@Composable
fun PopulatedFeed() {
ForYouScreen(
uiState = FeedWithoutTopicSelection(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
MaterialTheme {
Surface {
ForYouScreen(
uiState = FeedWithoutTopicSelection(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}

@ -22,8 +22,11 @@ import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.domain.combine
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
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.FollowableAuthor
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
@ -41,26 +44,31 @@ import kotlinx.coroutines.launch
@HiltViewModel
class ForYouViewModel @Inject constructor(
private val authorsRepository: AuthorsRepository,
private val topicsRepository: TopicsRepository,
private val newsRepository: NewsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val followedTopicsStateFlow = topicsRepository.getFollowedTopicIdsStream()
.map { followedTopics ->
if (followedTopics.isEmpty()) {
FollowedTopicsState.None
private val followedInterestsStateFlow =
combine(
authorsRepository.getFollowedAuthorIdsStream(),
topicsRepository.getFollowedTopicIdsStream(),
) { followedAuthors, followedTopics ->
if (followedAuthors.isEmpty() || followedTopics.isEmpty()) {
FollowedInterestsState.None
} else {
FollowedTopicsState.FollowedTopics(
FollowedInterestsState.FollowedInterests(
authorIds = followedAuthors,
topicIds = followedTopics
)
}
}
.stateIn(
viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FollowedTopicsState.Unknown
)
.stateIn(
viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FollowedInterestsState.Unknown
)
/**
* TODO: Temporary saving of news resources persisted through process death with a
@ -80,12 +88,23 @@ class ForYouViewModel @Inject constructor(
mutableStateOf<Set<Int>>(emptySet())
}
/**
* The in-progress set of authors to be selected, persisted through process death with a
* [SavedStateHandle].
*/
private var inProgressAuthorSelection by savedStateHandle.saveable {
mutableStateOf<Set<Int>>(emptySet())
}
val uiState: StateFlow<ForYouFeedUiState> = combine(
followedTopicsStateFlow,
followedInterestsStateFlow,
topicsRepository.getTopicsStream(),
snapshotFlow { inProgressTopicSelection },
authorsRepository.getAuthorsStream(),
snapshotFlow { inProgressAuthorSelection },
snapshotFlow { savedNewsResources }
) { followedTopicsUserState, availableTopics, inProgressTopicSelection, savedNewsResources ->
) { followedInterestsUserState, availableTopics, inProgressTopicSelection,
availableAuthors, inProgressAuthorSelection, savedNewsResources ->
fun mapToSaveableFeed(feed: List<NewsResource>): List<SaveableNewsResource> =
feed.map { newsResource ->
@ -95,13 +114,14 @@ class ForYouViewModel @Inject constructor(
)
}
when (followedTopicsUserState) {
when (followedInterestsUserState) {
// If we don't know the current selection state, just emit loading.
FollowedTopicsState.Unknown -> flowOf<ForYouFeedUiState>(ForYouFeedUiState.Loading)
FollowedInterestsState.Unknown -> flowOf<ForYouFeedUiState>(ForYouFeedUiState.Loading)
// If the user has followed topics, use those followed topics to populate the feed
is FollowedTopicsState.FollowedTopics -> {
is FollowedInterestsState.FollowedInterests -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = followedTopicsUserState.topicIds
filterTopicIds = followedInterestsUserState.topicIds,
filterAuthorIds = followedInterestsUserState.authorIds
)
.map(::mapToSaveableFeed)
.map { feed ->
@ -112,19 +132,26 @@ class ForYouViewModel @Inject constructor(
}
// If the user hasn't followed topics yet, show the topic selection, as well as a
// realtime populated feed based on those in-progress topic selections.
FollowedTopicsState.None -> {
FollowedInterestsState.None -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection
filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection
)
.map(::mapToSaveableFeed)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = availableTopics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in inProgressTopicSelection
)
},
authors = availableAuthors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in inProgressAuthorSelection
)
},
feed = feed
)
}
@ -153,6 +180,18 @@ class ForYouViewModel @Inject constructor(
}
}
fun updateAuthorSelection(authorId: Int, isChecked: Boolean) {
withMutableSnapshot {
inProgressAuthorSelection =
// Update the in-progress selection based on whether the author id was checked
if (isChecked) {
inProgressAuthorSelection + authorId
} else {
inProgressAuthorSelection - authorId
}
}
}
fun updateNewsResourceSaved(newsResourceId: Int, isChecked: Boolean) {
withMutableSnapshot {
savedNewsResources =
@ -164,41 +203,48 @@ class ForYouViewModel @Inject constructor(
}
}
fun saveFollowedTopics() {
if (inProgressTopicSelection.isEmpty()) return
viewModelScope.launch {
topicsRepository.setFollowedTopicIds(inProgressTopicSelection)
// Clear out the in-progress selection after saving it
withMutableSnapshot {
inProgressTopicSelection = emptySet()
fun saveFollowedInterests() {
if (inProgressTopicSelection.isNotEmpty()) {
viewModelScope.launch {
topicsRepository.setFollowedTopicIds(inProgressTopicSelection)
withMutableSnapshot {
inProgressTopicSelection = emptySet()
}
}
}
if (inProgressAuthorSelection.isNotEmpty()) {
viewModelScope.launch {
authorsRepository.setFollowedAuthorIds(inProgressAuthorSelection)
withMutableSnapshot {
inProgressAuthorSelection = emptySet()
}
}
}
}
}
/**
* A sealed hierarchy for the user's current followed topics state.
* A sealed hierarchy for the user's current followed interests state.
*/
private sealed interface FollowedTopicsState {
private sealed interface FollowedInterestsState {
/**
* The current state is unknown (hasn't loaded yet)
*/
object Unknown : FollowedTopicsState
object Unknown : FollowedInterestsState
/**
* The user hasn't followed any topics yet.
* The user hasn't followed any interests yet.
*/
object None : FollowedTopicsState
object None : FollowedInterestsState
/**
* The user has followed the given (non-empty) set of [topicIds].
* The user has followed the given (non-empty) set of [topicIds] or [authorIds].
*/
data class FollowedTopics(
data class FollowedInterests(
val topicIds: Set<Int>,
) : FollowedTopicsState
val authorIds: Set<Int>
) : FollowedInterestsState
}
/**
@ -222,13 +268,15 @@ sealed interface ForYouFeedUiState {
val feed: List<SaveableNewsResource>
/**
* The feed, along with a list of topics that can be selected.
* The feed, along with a list of interests that can be selected.
*/
data class FeedWithTopicSelection(
data class FeedWithInterestsSelection(
val topics: List<FollowableTopic>,
val authors: List<FollowableAuthor>,
override val feed: List<SaveableNewsResource>
) : PopulatedFeed {
val canSaveSelectedTopics: Boolean = topics.any { it.isFollowed }
val canSaveSelectedTopics: Boolean =
topics.any { it.isFollowed } || authors.any { it.isFollowed }
}
/**

@ -22,4 +22,9 @@
<string name="navigate_up">Navigate up</string>
<string name="onboarding_guidance_title">What are you interested in?</string>
<string name="onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string>
<!-- Authors-->
<string name="following">You are following</string>
<string name="not_following">You are not following</string>
</resources>

@ -18,11 +18,14 @@ 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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
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.TestAuthorsRepository
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
@ -37,6 +40,7 @@ class ForYouViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private lateinit var viewModel: ForYouViewModel
@ -44,6 +48,7 @@ class ForYouViewModelTest {
@Before
fun setup() {
viewModel = ForYouViewModel(
authorsRepository = authorsRepository,
topicsRepository = topicsRepository,
newsRepository = newsRepository,
savedStateHandle = SavedStateHandle()
@ -68,6 +73,16 @@ class ForYouViewModelTest {
}
}
@Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
viewModel.uiState.test {
assertEquals(ForYouFeedUiState.Loading, awaitItem())
authorsRepository.sendAuthors(sampleAuthors)
cancel()
}
}
@Test
fun stateIsLoadingWhenTopicsAreLoading() = runTest {
viewModel.uiState.test {
@ -78,28 +93,42 @@ class ForYouViewModelTest {
}
}
@Test
fun stateIsLoadingWhenAuthorsAreLoading() = runTest {
viewModel.uiState.test {
assertEquals(ForYouFeedUiState.Loading, awaitItem())
authorsRepository.setFollowedAuthorIds(emptySet())
cancel()
}
}
@Test
fun stateIsLoadingWhenNewsResourcesAreLoading() = runTest {
viewModel.uiState.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(emptySet())
cancel()
}
}
@Test
fun stateIsTopicSelectionAfterLoadingEmptyFollowedTopics() = runTest {
fun stateIsTopicSelectionAfterLoadingEmptyFollowedTopicsAnAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
authorsRepository.sendAuthors(sampleAuthors)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -135,6 +164,38 @@ class ForYouViewModelTest {
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
),
feed = emptyList()
),
awaitItem()
@ -148,6 +209,34 @@ class ForYouViewModelTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(setOf(0, 1))
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(setOf(0, 1))
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = sampleNewsResources.map {
SaveableNewsResource(
newsResource = it,
isSaved = false
)
}
),
awaitItem()
)
cancel()
}
}
@Test
fun stateIsWithoutTopicSelectionAfterLoadingFollowedAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(setOf(0, 1))
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(setOf(0, 1))
newsRepository.sendNewsResources(sampleNewsResources)
@ -174,13 +263,15 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -216,6 +307,138 @@ class ForYouViewModelTest {
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
),
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
)
),
awaitItem()
)
cancel()
}
}
@Test
fun topicSelectionUpdatesAfterSelectingAuthor() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateAuthorSelection(1, isChecked = true)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
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
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = true
),
FollowableAuthor(
author = Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
),
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
@ -240,6 +463,8 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
@ -249,7 +474,7 @@ class ForYouViewModelTest {
viewModel.updateTopicSelection(1, isChecked = false)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -285,6 +510,38 @@ class ForYouViewModelTest {
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
),
feed = emptyList()
),
awaitItem()
@ -294,19 +551,118 @@ class ForYouViewModelTest {
}
@Test
fun topicSelectionUpdatesAfterSavingTopics() = runTest {
fun topicSelectionUpdatesAfterUnselectingAuthor() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateAuthorSelection(1, isChecked = true)
awaitItem()
viewModel.updateAuthorSelection(1, isChecked = false)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
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
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
),
feed = emptyList()
),
awaitItem()
)
cancel()
}
}
@Test
fun topicSelectionUpdatesAfterSavingAuthorsAndTopics() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateAuthorSelection(1, isChecked = true)
viewModel.updateTopicSelection(1, isChecked = true)
awaitItem()
viewModel.saveFollowedInterests()
awaitItem()
viewModel.saveFollowedTopics()
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
@ -324,6 +680,7 @@ class ForYouViewModelTest {
awaitItem()
)
assertEquals(setOf(1), topicsRepository.getCurrentFollowedTopics())
assertEquals(setOf(1), authorsRepository.getCurrentFollowedAuthors())
cancel()
}
}
@ -335,19 +692,18 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
viewModel.updateTopicSelection(1, isChecked = true)
viewModel.saveFollowedInterests()
awaitItem()
viewModel.saveFollowedTopics()
awaitItem()
topicsRepository.setFollowedTopicIds(emptySet())
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -383,7 +739,39 @@ class ForYouViewModelTest {
isFollowed = false
)
),
feed = emptyList()
feed = emptyList(),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
)
),
awaitItem()
)
@ -398,6 +786,8 @@ class ForYouViewModelTest {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(setOf(1))
authorsRepository.sendAuthors(sampleAuthors)
authorsRepository.setFollowedAuthorIds(setOf(1))
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved(2, true)
@ -421,6 +811,30 @@ class ForYouViewModelTest {
}
}
private val sampleAuthors = listOf(
Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
)
)
private val sampleTopics = listOf(
Topic(
id = 0,
@ -471,7 +885,15 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL",
)
),
authors = emptyList()
authors = listOf(
Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
)
)
),
NewsResource(
id = 2,
@ -494,7 +916,15 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL",
),
),
authors = emptyList()
authors = listOf(
Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
)
)
),
NewsResource(
id = 3,
@ -515,6 +945,14 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL",
),
),
authors = emptyList()
authors = listOf(
Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
)
)
),
)

@ -89,7 +89,6 @@ class TopicScreenTest {
@Test
fun news_whenTopicIsLoading_isNotShown() {
val testTopic = testTopics.first()
composeTestRule.setContent {
TopicScreen(
topicState = TopicUiState.Loading,
@ -133,10 +132,10 @@ private val testTopics = listOf(
Topic(
id = 0,
name = TOPIC_1_NAME,
longDescription = TOPIC_DESC,
shortDescription = "",
imageUrl = "",
url = ""
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
),
isFollowed = true
),
@ -144,10 +143,10 @@ private val testTopics = listOf(
Topic(
id = 1,
name = TOPIC_2_NAME,
longDescription = TOPIC_DESC,
shortDescription = "",
imageUrl = "",
url = ""
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
),
isFollowed = false
),
@ -155,17 +154,15 @@ private val testTopics = listOf(
Topic(
id = 2,
name = TOPIC_3_NAME,
longDescription = TOPIC_DESC,
shortDescription = "",
imageUrl = "",
url = ""
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
),
isFollowed = false
)
)
private val numberOfUnfollowedTopics = testTopics.filter { !it.isFollowed }.size
private val sampleNewsResources = listOf(
NewsResource(
id = 1,
@ -184,9 +181,9 @@ private val sampleNewsResources = listOf(
id = 0,
name = "Headlines",
shortDescription = "",
longDescription = "",
imageUrl = "",
url = ""
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
)
),
authors = emptyList()

@ -70,7 +70,7 @@ fun TopicRoute(
@VisibleForTesting
@Composable
fun TopicScreen(
internal fun TopicScreen(
topicState: TopicUiState,
newsState: NewsUiState,
onBackClick: () -> Unit,

@ -53,7 +53,7 @@ class TopicViewModel @Inject constructor(
// Observe the News for this topic
private val newsStream: Flow<Result<List<NewsResource>>> =
newsRepository.getNewsResourcesStream(setOf(topicId)).asResult()
newsRepository.getNewsResourcesStream(setOf(topicId), emptySet()).asResult()
val uiState: StateFlow<TopicScreenUiState> =
combine(

Loading…
Cancel
Save