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.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiADatabase 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.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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -38,6 +40,7 @@ class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao private lateinit var newsResourceDao: NewsResourceDao
private lateinit var episodeDao: EpisodeDao private lateinit var episodeDao: EpisodeDao
private lateinit var topicDao: TopicDao private lateinit var topicDao: TopicDao
private lateinit var authorDao: AuthorDao
private lateinit var db: NiADatabase private lateinit var db: NiADatabase
@Before @Before
@ -50,6 +53,7 @@ class NewsResourceDaoTest {
newsResourceDao = db.newsResourceDao() newsResourceDao = db.newsResourceDao()
episodeDao = db.episodeDao() episodeDao = db.episodeDao()
topicDao = db.topicDao() topicDao = db.topicDao()
authorDao = db.authorDao()
} }
@Test @Test
@ -147,12 +151,69 @@ class NewsResourceDaoTest {
newsResourceTopicCrossRefEntities newsResourceTopicCrossRefEntities
) )
val filteredNewsResources = newsResourceDao.getNewsResourcesStream( val filteredNewsResources = newsResourceDao.getNewsResourcesForTopicsStream(
filterTopicIds = topicEntities filterTopicIds = topicEntities
.map(TopicEntity::id) .map(TopicEntity::id)
.toSet() .toSet()
).first() ).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( assertEquals(
listOf(1, 0), listOf(1, 0),
filteredNewsResources.map { it.entity.id } 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( private fun testTopicEntity(
id: Int = 0, id: Int = 0,
name: String name: String

@ -53,7 +53,43 @@ interface NewsResourceDao {
ORDER BY publish_date DESC 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 * 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) 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 authorChangeListVersion = 4;
int32 episodeChangeListVersion = 5; int32 episodeChangeListVersion = 5;
int32 newsResourceChangeListVersion = 6; 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.datastore.NiaPreferences
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlin.coroutines.cancellation.CancellationException 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] * Attempts [block], returning a successful [Result] if it succeeds, otherwise a [Result.Failure]
@ -71,3 +73,29 @@ suspend fun changeListSync(
versionUpdater(latestVersion) versionUpdater(latestVersion)
} }
}.isSuccess }.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>> 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. * Synchronizes the local database in backing the repository with the network.
* Returns if the sync was successful or not. * Returns if the sync was successful or not.

@ -43,6 +43,14 @@ class LocalAuthorsRepository @Inject constructor(
authorDao.getAuthorEntitiesStream() authorDao.getAuthorEntitiesStream()
.map { it.map(AuthorEntity::asExternalModel) } .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( override suspend fun sync(): Boolean = changeListSync(
niaPreferences = niaPreferences, niaPreferences = niaPreferences,
versionReader = ChangeListVersions::authorVersion, versionReader = ChangeListVersions::authorVersion,

@ -57,8 +57,20 @@ class LocalNewsRepository @Inject constructor(
newsResourceDao.getNewsResourcesStream() newsResourceDao.getNewsResourcesStream()
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>> = override fun getNewsResourcesStream(
newsResourceDao.getNewsResourcesStream(filterTopicIds = filterTopicIds) 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) } .map { it.map(PopulatedNewsResource::asExternalModel) }
override suspend fun sync() = changeListSync( override suspend fun sync() = changeListSync(

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

@ -54,11 +54,17 @@ class FakeNewsRepository @Inject constructor(
} }
.flowOn(ioDispatcher) .flowOn(ioDispatcher)
override fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>> = override fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int>,
): Flow<List<NewsResource>> =
flow { flow {
emit( emit(
networkJson.decodeFromString<List<NetworkNewsResource>>(FakeDataSource.data) 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(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .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.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork 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.TestTopicDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.filteredTopicIds import com.google.samples.apps.nowinandroid.core.domain.testdoubles.filteredInterestsIds
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.nonPresentTopicIds 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.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -101,19 +101,49 @@ class LocalNewsRepositoryTest {
} }
@Test @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 { runTest {
assertEquals( assertEquals(
newsResourceDao.getNewsResourcesStream(filterTopicIds = filteredTopicIds) newsResourceDao.getNewsResourcesForTopicsStream(filteredInterestsIds)
.first() .first()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResourcesStream(filterTopicIds = filteredTopicIds) subject.getNewsResourcesStream(
filterTopicIds = filteredInterestsIds,
filterAuthorIds = emptySet()
)
.first()
)
assertEquals(
emptyList<NewsResource>(),
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() .first()
) )
assertEquals( assertEquals(
emptyList<NewsResource>(), emptyList<NewsResource>(),
subject.getNewsResourcesStream(filterTopicIds = nonPresentTopicIds) subject.getNewsResourcesStream(
filterTopicIds = emptySet(),
filterAuthorIds = nonPresentInterestsIds
)
.first() .first()
) )
} }

@ -30,8 +30,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
val filteredTopicIds = setOf(1) val filteredInterestsIds = setOf(1)
val nonPresentTopicIds = setOf(2) val nonPresentInterestsIds = setOf(2)
/** /**
* Test double for [NewsResourceDao] * Test double for [NewsResourceDao]
@ -63,15 +63,33 @@ class TestNewsResourceDao : NewsResourceDao {
} }
override fun getNewsResourcesStream( override fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int> filterTopicIds: Set<Int>
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> =
getNewsResourcesStream() getNewsResourcesStream()
.map { resources -> .map { resources ->
resources.filter { resource -> 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( override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity> entities: List<NewsResourceEntity>
): List<Long> { ): List<Long> {
@ -108,7 +126,7 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
), ),
authors = listOf( authors = listOf(
AuthorEntity( AuthorEntity(
id = 2, id = this.episodeId,
name = "name", name = "name",
imageUrl = "imageUrl", imageUrl = "imageUrl",
twitter = "twitter", twitter = "twitter",
@ -117,7 +135,7 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
), ),
topics = listOf( topics = listOf(
TopicEntity( TopicEntity(
id = filteredTopicIds.random(), id = filteredInterestsIds.random(),
name = "name", name = "name",
shortDescription = "short description", shortDescription = "short description",
longDescription = "long 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 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.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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@ -35,10 +36,15 @@ class TestNewsRepository : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> = newsResourcesFlow override fun getNewsResourcesStream(): Flow<List<NewsResource>> = newsResourcesFlow
override fun getNewsResourcesStream( override fun getNewsResourcesStream(
filterAuthorIds: Set<Int>,
filterTopicIds: Set<Int> filterTopicIds: Set<Int>
): Flow<List<NewsResource>> = ): Flow<List<NewsResource>> =
getNewsResourcesStream().map { newsResources -> 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 package com.google.samples.apps.nowinandroid.feature.following
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconToggleButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons 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.Android
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -48,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.NiaLoadingIndicator
import com.google.samples.apps.nowinandroid.core.ui.NiaToolbar import com.google.samples.apps.nowinandroid.core.ui.NiaToolbar
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
@ -131,7 +126,7 @@ fun FollowingTopicCard(
onFollowButtonClick: (Int, Boolean) -> Unit, onFollowButtonClick: (Int, Boolean) -> Unit,
) { ) {
Row( Row(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
Modifier.padding( Modifier.padding(
start = 24.dp, start = 24.dp,
@ -154,9 +149,16 @@ fun FollowingTopicCard(
TopicDescription(topicDescription = followableTopic.topic.shortDescription) TopicDescription(topicDescription = followableTopic.topic.shortDescription)
} }
FollowButton( FollowButton(
topicId = followableTopic.topic.id, following = followableTopic.isFollowed,
onClick = onFollowButtonClick, onFollowChange = { following ->
isFollowed = followableTopic.isFollowed 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") @Preview("Topic card")
@Composable @Composable
fun TopicCardPreview() { fun TopicCardPreview() {

@ -44,6 +44,9 @@ dependencies {
implementation libs.accompanist.flowlayout implementation libs.accompanist.flowlayout
implementation libs.coil.kt
implementation libs.coil.kt.compose
implementation libs.hilt.android implementation libs.hilt.android
kapt libs.hilt.compiler kapt libs.hilt.compiler

@ -21,9 +21,13 @@ import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled 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.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText 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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import org.junit.Rule import org.junit.Rule
@ -38,6 +42,7 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.Loading, uiState = ForYouFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -55,7 +60,7 @@ class ForYouScreenTest {
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() { fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection( uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -63,8 +68,8 @@ class ForYouScreenTest {
name = "Headlines", name = "Headlines",
shortDescription = "", shortDescription = "",
longDescription = "", longDescription = "",
imageUrl = "", url = "",
url = "" imageUrl = ""
), ),
isFollowed = false isFollowed = false
), ),
@ -74,8 +79,8 @@ class ForYouScreenTest {
name = "UI", name = "UI",
shortDescription = "", shortDescription = "",
longDescription = "", longDescription = "",
imageUrl = "", url = "",
url = "" imageUrl = ""
), ),
isFollowed = false isFollowed = false
), ),
@ -85,14 +90,37 @@ class ForYouScreenTest {
name = "Tools", name = "Tools",
shortDescription = "", shortDescription = "",
longDescription = "", longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = 0,
name = "Android Dev",
imageUrl = "", imageUrl = "",
url = "" twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
), ),
isFollowed = false isFollowed = false
), ),
), ),
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -125,7 +153,7 @@ class ForYouScreenTest {
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection( uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -133,8 +161,8 @@ class ForYouScreenTest {
name = "Headlines", name = "Headlines",
shortDescription = "", shortDescription = "",
longDescription = "", longDescription = "",
imageUrl = "", url = "",
url = "" imageUrl = ""
), ),
isFollowed = false isFollowed = false
), ),
@ -144,8 +172,8 @@ class ForYouScreenTest {
name = "UI", name = "UI",
shortDescription = "", shortDescription = "",
longDescription = "", longDescription = "",
imageUrl = "", url = "",
url = "" imageUrl = ""
), ),
isFollowed = true isFollowed = true
), ),
@ -155,14 +183,37 @@ class ForYouScreenTest {
name = "Tools", name = "Tools",
shortDescription = "", shortDescription = "",
longDescription = "", 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 = "", imageUrl = "",
url = "" twitter = "",
mediumPage = ""
), ),
isFollowed = false isFollowed = false
), ),
), ),
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -184,6 +235,117 @@ class ForYouScreenTest {
.assertIsDisplayed() .assertIsDisplayed()
.assertHasClickAction() .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 composeTestRule
.onNodeWithText(composeTestRule.activity.resources.getString(R.string.done)) .onNodeWithText(composeTestRule.activity.resources.getString(R.string.done))
.assertIsDisplayed() .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.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -54,6 +54,8 @@ import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.hilt.navigation.compose.hiltViewModel 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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
@ -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.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTypography 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
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 com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
@ -79,7 +81,8 @@ fun ForYouRoute(
modifier = modifier, modifier = modifier,
uiState = uiState, uiState = uiState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
saveFollowedTopics = viewModel::saveFollowedTopics, onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved
) )
} }
@ -88,6 +91,7 @@ fun ForYouRoute(
fun ForYouScreen( fun ForYouScreen(
uiState: ForYouFeedUiState, uiState: ForYouFeedUiState,
onTopicCheckedChanged: (Int, Boolean) -> Unit, onTopicCheckedChanged: (Int, Boolean) -> Unit,
onAuthorCheckedChanged: (Int, Boolean) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (Int, Boolean) -> Unit, onNewsResourcesCheckedChanged: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -106,9 +110,40 @@ fun ForYouScreen(
} }
is PopulatedFeed -> { is PopulatedFeed -> {
when (uiState) { when (uiState) {
is FeedWithTopicSelection -> { is FeedWithInterestsSelection -> {
item { 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 { item {
// Done button // Done button
@ -153,33 +188,16 @@ fun ForYouScreen(
@Composable @Composable
private fun TopicSelection( private fun TopicSelection(
uiState: ForYouFeedUiState, uiState: FeedWithInterestsSelection,
onTopicCheckedChanged: (Int, Boolean) -> Unit 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( LazyHorizontalGrid(
rows = Fixed(3), rows = Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.dp), contentPadding = PaddingValues(24.dp),
modifier = Modifier modifier = modifier
// LazyHorizontalGrid has to be constrained in height. // LazyHorizontalGrid has to be constrained in height.
// However, we can't set a fixed height because the horizontal grid contains // However, we can't set a fixed height because the horizontal grid contains
// vertical text that can be rescaled. // vertical text that can be rescaled.
@ -192,8 +210,7 @@ private fun TopicSelection(
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() })) .heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth() .fillMaxWidth()
) { ) {
val state: FeedWithTopicSelection = uiState as FeedWithTopicSelection items(uiState.topics) {
items(state.topics) {
SingleTopicButton( SingleTopicButton(
name = it.topic.name, name = it.topic.name,
topicId = it.topic.id, topicId = it.topic.id,
@ -202,7 +219,6 @@ private fun TopicSelection(
) )
} }
} }
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -230,7 +246,10 @@ private fun SingleTopicButton(
Text( Text(
text = name, text = name,
style = NiaTypography.titleSmall, style = NiaTypography.titleSmall,
modifier = Modifier.padding(12.dp).weight(1f), modifier = Modifier
.padding(12.dp)
.weight(1f),
color = MaterialTheme.colorScheme.onSurface
) )
NiaToggleButton( NiaToggleButton(
checked = isSelected, checked = isSelected,
@ -249,19 +268,24 @@ private fun SingleTopicButton(
@Preview @Preview
@Composable @Composable
fun ForYouScreenLoading() { fun ForYouScreenLoading() {
MaterialTheme {
Surface {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.Loading, uiState = ForYouFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
}
}
} }
@Preview @Preview
@Composable @Composable
fun ForYouScreenTopicSelection() { fun ForYouScreenTopicSelection() {
ForYouScreen( ForYouScreen(
uiState = FeedWithTopicSelection( uiState = FeedWithInterestsSelection(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -376,8 +400,41 @@ fun ForYouScreenTopicSelection() {
), ),
isSaved = false 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 = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -387,12 +444,17 @@ fun ForYouScreenTopicSelection() {
@Preview @Preview
@Composable @Composable
fun PopulatedFeed() { fun PopulatedFeed() {
MaterialTheme {
Surface {
ForYouScreen( ForYouScreen(
uiState = FeedWithoutTopicSelection( uiState = FeedWithoutTopicSelection(
feed = emptyList() feed = emptyList()
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
}
}
} }

@ -22,8 +22,11 @@ import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.NewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository 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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
@ -41,17 +44,22 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
private val authorsRepository: AuthorsRepository,
private val topicsRepository: TopicsRepository, private val topicsRepository: TopicsRepository,
private val newsRepository: NewsRepository, private val newsRepository: NewsRepository,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
private val followedTopicsStateFlow = topicsRepository.getFollowedTopicIdsStream() private val followedInterestsStateFlow =
.map { followedTopics -> combine(
if (followedTopics.isEmpty()) { authorsRepository.getFollowedAuthorIdsStream(),
FollowedTopicsState.None topicsRepository.getFollowedTopicIdsStream(),
) { followedAuthors, followedTopics ->
if (followedAuthors.isEmpty() || followedTopics.isEmpty()) {
FollowedInterestsState.None
} else { } else {
FollowedTopicsState.FollowedTopics( FollowedInterestsState.FollowedInterests(
authorIds = followedAuthors,
topicIds = followedTopics topicIds = followedTopics
) )
} }
@ -59,7 +67,7 @@ class ForYouViewModel @Inject constructor(
.stateIn( .stateIn(
viewModelScope, viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = FollowedTopicsState.Unknown initialValue = FollowedInterestsState.Unknown
) )
/** /**
@ -80,12 +88,23 @@ class ForYouViewModel @Inject constructor(
mutableStateOf<Set<Int>>(emptySet()) 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( val uiState: StateFlow<ForYouFeedUiState> = combine(
followedTopicsStateFlow, followedInterestsStateFlow,
topicsRepository.getTopicsStream(), topicsRepository.getTopicsStream(),
snapshotFlow { inProgressTopicSelection }, snapshotFlow { inProgressTopicSelection },
authorsRepository.getAuthorsStream(),
snapshotFlow { inProgressAuthorSelection },
snapshotFlow { savedNewsResources } snapshotFlow { savedNewsResources }
) { followedTopicsUserState, availableTopics, inProgressTopicSelection, savedNewsResources -> ) { followedInterestsUserState, availableTopics, inProgressTopicSelection,
availableAuthors, inProgressAuthorSelection, savedNewsResources ->
fun mapToSaveableFeed(feed: List<NewsResource>): List<SaveableNewsResource> = fun mapToSaveableFeed(feed: List<NewsResource>): List<SaveableNewsResource> =
feed.map { newsResource -> 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. // 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 // If the user has followed topics, use those followed topics to populate the feed
is FollowedTopicsState.FollowedTopics -> { is FollowedInterestsState.FollowedInterests -> {
newsRepository.getNewsResourcesStream( newsRepository.getNewsResourcesStream(
filterTopicIds = followedTopicsUserState.topicIds filterTopicIds = followedInterestsUserState.topicIds,
filterAuthorIds = followedInterestsUserState.authorIds
) )
.map(::mapToSaveableFeed) .map(::mapToSaveableFeed)
.map { feed -> .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 // 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. // realtime populated feed based on those in-progress topic selections.
FollowedTopicsState.None -> { FollowedInterestsState.None -> {
newsRepository.getNewsResourcesStream( newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection
) )
.map(::mapToSaveableFeed) .map(::mapToSaveableFeed)
.map { feed -> .map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection( ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = availableTopics.map { topic -> topics = availableTopics.map { topic ->
FollowableTopic( FollowableTopic(
topic = topic, topic = topic,
isFollowed = topic.id in inProgressTopicSelection isFollowed = topic.id in inProgressTopicSelection
) )
}, },
authors = availableAuthors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in inProgressAuthorSelection
)
},
feed = feed 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) { fun updateNewsResourceSaved(newsResourceId: Int, isChecked: Boolean) {
withMutableSnapshot { withMutableSnapshot {
savedNewsResources = savedNewsResources =
@ -164,41 +203,48 @@ class ForYouViewModel @Inject constructor(
} }
} }
fun saveFollowedTopics() { fun saveFollowedInterests() {
if (inProgressTopicSelection.isEmpty()) return if (inProgressTopicSelection.isNotEmpty()) {
viewModelScope.launch { viewModelScope.launch {
topicsRepository.setFollowedTopicIds(inProgressTopicSelection) topicsRepository.setFollowedTopicIds(inProgressTopicSelection)
// Clear out the in-progress selection after saving it
withMutableSnapshot { withMutableSnapshot {
inProgressTopicSelection = emptySet() 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) * 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>, val topicIds: Set<Int>,
) : FollowedTopicsState val authorIds: Set<Int>
) : FollowedInterestsState
} }
/** /**
@ -222,13 +268,15 @@ sealed interface ForYouFeedUiState {
val feed: List<SaveableNewsResource> 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 topics: List<FollowableTopic>,
val authors: List<FollowableAuthor>,
override val feed: List<SaveableNewsResource> override val feed: List<SaveableNewsResource>
) : PopulatedFeed { ) : 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="navigate_up">Navigate up</string>
<string name="onboarding_guidance_title">What are you interested in?</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> <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> </resources>

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

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

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

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

Loading…
Cancel
Save