From de2f07d1a468d4b2cac74ae684b6a78d8ec5e561 Mon Sep 17 00:00:00 2001 From: Manuel Vivo Date: Mon, 4 Apr 2022 18:49:58 +0200 Subject: [PATCH] Add People carousel Screenshot: https://screenshot.googleplex.com/9K6C4NZMfMzCABE.png Change-Id: I32b0240910df6a953c8843895f3b7e22d5adc5de --- .../core/database/dao/NewsResourceDaoTest.kt | 74 ++- .../core/database/dao/NewsResourceDao.kt | 38 +- .../core/datastore/NiaPreferences.kt | 39 ++ .../nowinandroid/data/user_preferences.proto | 1 + .../nowinandroid/core/domain/SyncUtilities.kt | 28 ++ .../domain/repository/AuthorsRepository.kt | 15 + .../repository/LocalAuthorsRepository.kt | 8 + .../domain/repository/LocalNewsRepository.kt | 18 +- .../core/domain/repository/NewsRepository.kt | 7 +- .../repository/fake/FakeAuthorsRepository.kt | 16 +- .../repository/fake/FakeNewsRepository.kt | 10 +- .../repository/LocalNewsRepositoryTest.kt | 42 +- .../domain/testdoubles/TestNewsResourceDao.kt | 28 +- .../core/model/data/FollowableAuthor.kt | 25 + .../repository/TestAuthorsRepository.kt | 68 +++ .../testing/repository/TestNewsRepository.kt | 8 +- .../apps/nowinandroid/core/ui/FollowButton.kt | 106 ++++ .../feature/following/FollowingScreen.kt | 66 +-- feature-foryou/build.gradle | 5 +- .../feature/foryou/ForYouScreenTest.kt | 186 ++++++- .../feature/foryou/AuthorsCarousel.kt | 165 ++++++ .../feature/foryou/ForYouScreen.kt | 200 +++++--- .../feature/foryou/ForYouViewModel.kt | 126 +++-- .../src/main/res/values/strings.xml | 5 + .../feature/foryou/ForYouViewModelTest.kt | 470 +++++++++++++++++- .../feature/topic/TopicScreenTest.kt | 27 +- .../nowinandroid/feature/topic/TopicScreen.kt | 2 +- .../feature/topic/TopicViewModel.kt | 2 +- 28 files changed, 1554 insertions(+), 231 deletions(-) create mode 100644 core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableAuthor.kt create mode 100644 core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt create mode 100644 core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowButton.kt create mode 100644 feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt diff --git a/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt b/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt index 362a10075..dbde3eade 100644 --- a/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt +++ b/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt @@ -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 diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index 44713a82e..44adb67fb 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -53,7 +53,43 @@ interface NewsResourceDao { ORDER BY publish_date DESC """ ) - fun getNewsResourcesStream(filterTopicIds: Set): Flow> + fun getNewsResourcesForTopicsStream( + filterTopicIds: Set + ): Flow> + + @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 + ): Flow> + + @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, + filterTopicIds: Set + ): Flow> /** * Inserts [entities] into the db if they don't exist, and ignores those that do diff --git a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt index c09220ea4..c1a6a33f5 100644 --- a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt +++ b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt @@ -104,4 +104,43 @@ class NiaPreferences @Inject constructor( Log.e("NiaPreferences", "Failed to update user preferences", ioException) } } + + suspend fun setFollowedAuthorIds(followedAuthorIds: Set) { + 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> = userPreferences.data + .retry { + Log.e("NiaPreferences", "Failed to read user preferences", it) + true + } + .map { it.followedAuthorIdsList.toSet() } } diff --git a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto index 0a8b9d90c..ec6689dc8 100644 --- a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto +++ b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto @@ -26,4 +26,5 @@ message UserPreferences { int32 authorChangeListVersion = 4; int32 episodeChangeListVersion = 5; int32 newsResourceChangeListVersion = 6; + repeated int32 followed_author_ids = 7; } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt index 41637e9fb..c544602c9 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt @@ -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 combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow = 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 + ) +} diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt index 2fc5e6f7d..39fbf8f18 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt @@ -25,6 +25,21 @@ interface AuthorsRepository { */ fun getAuthorsStream(): Flow> + /** + * Sets the user's currently followed authors + */ + suspend fun setFollowedAuthorIds(followedAuthorIds: Set) + + /** + * Toggles the user's newly followed/unfollowed author + */ + suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) + + /** + * Returns the users currently followed authors + */ + fun getFollowedAuthorIdsStream(): Flow> + /** * Synchronizes the local database in backing the repository with the network. * Returns if the sync was successful or not. diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt index cd8576e50..18f839dcb 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt @@ -43,6 +43,14 @@ class LocalAuthorsRepository @Inject constructor( authorDao.getAuthorEntitiesStream() .map { it.map(AuthorEntity::asExternalModel) } + override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) = + niaPreferences.setFollowedAuthorIds(followedAuthorIds) + + override suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) = + niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed) + + override fun getFollowedAuthorIdsStream(): Flow> = niaPreferences.followedAuthorIds + override suspend fun sync(): Boolean = changeListSync( niaPreferences = niaPreferences, versionReader = ChangeListVersions::authorVersion, diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt index bd3a0370d..e6f9a5e9a 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt @@ -57,9 +57,21 @@ class LocalNewsRepository @Inject constructor( newsResourceDao.getNewsResourcesStream() .map { it.map(PopulatedNewsResource::asExternalModel) } - override fun getNewsResourcesStream(filterTopicIds: Set): Flow> = - newsResourceDao.getNewsResourcesStream(filterTopicIds = filterTopicIds) - .map { it.map(PopulatedNewsResource::asExternalModel) } + override fun getNewsResourcesStream( + filterAuthorIds: Set, + filterTopicIds: Set + ): Flow> = 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, diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt index 9a54cb793..ac71f4387 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt @@ -29,9 +29,12 @@ interface NewsRepository { fun getNewsResourcesStream(): Flow> /** - * 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): Flow> + fun getNewsResourcesStream( + filterAuthorIds: Set, + filterTopicIds: Set, + ): Flow> /** * Synchronizes the local database in backing the repository with the network. diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt index 5ac6fb4aa..792771c32 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt @@ -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> = flow { emit( networkJson.decodeFromString>(FakeDataSource.authors).map { @@ -56,5 +58,15 @@ class FakeAuthorsRepository @Inject constructor( } .flowOn(ioDispatcher) + override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) { + niaPreferences.setFollowedAuthorIds(followedAuthorIds) + } + + override suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) { + niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed) + } + + override fun getFollowedAuthorIdsStream(): Flow> = niaPreferences.followedAuthorIds + override suspend fun sync() = true } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt index f25ef930a..fd4da95af 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt @@ -54,11 +54,17 @@ class FakeNewsRepository @Inject constructor( } .flowOn(ioDispatcher) - override fun getNewsResourcesStream(filterTopicIds: Set): Flow> = + override fun getNewsResourcesStream( + filterAuthorIds: Set, + filterTopicIds: Set, + ): Flow> = flow { emit( networkJson.decodeFromString>(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) ) diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt index d77826ed9..289624ea2 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt @@ -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(), - 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(), + subject.getNewsResourcesStream( + filterTopicIds = emptySet(), + filterAuthorIds = nonPresentInterestsIds + ) .first() ) } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt index 827849536..b911361bf 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt @@ -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, filterTopicIds: Set ): Flow> = 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 + ): Flow> = + getNewsResourcesStream() + .map { resources -> + resources.filter { resource -> resource.topics.any { it.id in filterTopicIds } } + } + + override fun getNewsResourcesForAuthorsStream( + filterAuthorIds: Set + ): Flow> = + getNewsResourcesStream() + .map { resources -> + resources.filter { resource -> resource.authors.any { it.id in filterAuthorIds } } + } + override suspend fun insertOrIgnoreNewsResources( entities: List ): List { @@ -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", diff --git a/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableAuthor.kt b/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableAuthor.kt new file mode 100644 index 000000000..14ef8bf11 --- /dev/null +++ b/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableAuthor.kt @@ -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 +) diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt new file mode 100644 index 000000000..1b5693f15 --- /dev/null +++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt @@ -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> = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** + * The backing hot flow for the list of author ids for testing. + */ + private val authorsFlow: MutableSharedFlow> = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override fun getAuthorsStream(): Flow> = authorsFlow + + override fun getFollowedAuthorIdsStream(): Flow> = _followedAuthorIds + + override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) { + _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) { + authorsFlow.tryEmit(authors) + } + + /** + * A test-only API to allow querying the current followed topics. + */ + fun getCurrentFollowedAuthors(): Set? = _followedAuthorIds.replayCache.firstOrNull() +} diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt index bcafa58c4..df9ddd927 100644 --- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt +++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt @@ -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> = newsResourcesFlow override fun getNewsResourcesStream( + filterAuthorIds: Set, filterTopicIds: Set ): Flow> = 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() + } } /** diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowButton.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowButton.kt new file mode 100644 index 000000000..320c00304 --- /dev/null +++ b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowButton.kt @@ -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) +} diff --git a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt index 3fa4e654c..dfd1f78c9 100644 --- a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt +++ b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt @@ -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() { diff --git a/feature-foryou/build.gradle b/feature-foryou/build.gradle index 01741e1c4..d62a8cacf 100644 --- a/feature-foryou/build.gradle +++ b/feature-foryou/build.gradle @@ -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' } } -} \ No newline at end of file +} diff --git a/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index f8325966a..a98ddfebc 100644 --- a/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -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() diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt new file mode 100644 index 000000000..c94b25050 --- /dev/null +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt @@ -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, + 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 = { } + ) + } + } +} diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 75a599bac..ec1127764 100644 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -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 = { _, _ -> } + ) + } + } } diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index f800eee29..9023c0314 100644 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -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>(emptySet()) } + /** + * The in-progress set of authors to be selected, persisted through process death with a + * [SavedStateHandle]. + */ + private var inProgressAuthorSelection by savedStateHandle.saveable { + mutableStateOf>(emptySet()) + } + val uiState: StateFlow = 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): List = 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.Loading) + FollowedInterestsState.Unknown -> flowOf(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, - ) : FollowedTopicsState + val authorIds: Set + ) : FollowedInterestsState } /** @@ -222,13 +268,15 @@ sealed interface ForYouFeedUiState { val feed: List /** - * 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, + val authors: List, override val feed: List ) : PopulatedFeed { - val canSaveSelectedTopics: Boolean = topics.any { it.isFollowed } + val canSaveSelectedTopics: Boolean = + topics.any { it.isFollowed } || authors.any { it.isFollowed } } /** diff --git a/feature-foryou/src/main/res/values/strings.xml b/feature-foryou/src/main/res/values/strings.xml index 586ee170a..be136b32d 100644 --- a/feature-foryou/src/main/res/values/strings.xml +++ b/feature-foryou/src/main/res/values/strings.xml @@ -22,4 +22,9 @@ Navigate up What are you interested in? Updates from topics you follow will appear here. Follow some things to get started. + + + You are following + You are not following + diff --git a/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index 09bd402c0..05044b2d5 100644 --- a/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -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 = "" + ) + ) ), ) diff --git a/feature-topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature-topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index 5151c972c..c306a9f4a 100644 --- a/feature-topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature-topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -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() diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index 96c201d5d..998c1811d 100644 --- a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -70,7 +70,7 @@ fun TopicRoute( @VisibleForTesting @Composable -fun TopicScreen( +internal fun TopicScreen( topicState: TopicUiState, newsState: NewsUiState, onBackClick: () -> Unit, diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index 9fa7d2b5b..fef44cf44 100644 --- a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -53,7 +53,7 @@ class TopicViewModel @Inject constructor( // Observe the News for this topic private val newsStream: Flow>> = - newsRepository.getNewsResourcesStream(setOf(topicId)).asResult() + newsRepository.getNewsResourcesStream(setOf(topicId), emptySet()).asResult() val uiState: StateFlow = combine(