diff --git a/README.md b/README.md index 29596e2bf..521a6cdea 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ follows Android design and development best practices and is intended to be a us for developers. As a running app, it's intended to help developers keep up-to-date with the world of Android development by providing regular news updates. -The app is currently in early stage development and is not yet available on the Play Store. +The app is currently in development. The `demoRelease` variant is [available on the Play Store in open beta](https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid). # Features diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3bf4fae6d..4dcbf6ef2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ plugins { android { defaultConfig { applicationId = "com.google.samples.apps.nowinandroid" - versionCode = 2 - versionName = "0.0.2" // X.Y.Z; X = Major, Y = minor, Z = Patch level + versionCode = 3 + versionName = "0.0.3" // X.Y.Z; X = Major, Y = minor, Z = Patch level // Custom test runner to set up Hilt dependency graph testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Flavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Flavor.kt index f3a12db4f..e251267ce 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Flavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Flavor.kt @@ -14,8 +14,8 @@ enum class FlavorDimension { // purposes, or from a production backend server which supplies up-to-date, real content. // These two product flavors reflect this behaviour. enum class Flavor (val dimension : FlavorDimension, val applicationIdSuffix : String? = null) { - demo(FlavorDimension.contentType, ".demo"), - prod(FlavorDimension.contentType) + demo(FlavorDimension.contentType), + prod(FlavorDimension.contentType, ".prod") } fun Project.configureFlavors( diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index 8af0d3711..e95c13460 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -50,4 +50,7 @@ class OfflineFirstUserDataRepository @Inject constructor( override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) = + niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt index 7554d3f03..8cd22c18f 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt @@ -62,4 +62,9 @@ interface UserDataRepository { * Sets the desired dark theme config. */ suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) + + /** + * Sets whether the user has completed the onboarding process. + */ + suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt index 58714cf40..4752cd7a1 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt @@ -63,4 +63,8 @@ class FakeUserDataRepository @Inject constructor( override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) } + + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { + niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) + } } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index fd4b2ccde..c1d740602 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -58,7 +58,8 @@ class OfflineFirstUserDataRepositoryTest { followedTopics = emptySet(), followedAuthors = emptySet(), themeBrand = ThemeBrand.DEFAULT, - darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM + darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, + shouldHideOnboarding = false ), subject.userDataStream.first() ) @@ -187,4 +188,15 @@ class OfflineFirstUserDataRepositoryTest { .first() ) } + + @Test + fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() = + runTest { + subject.setFollowedTopicIds(setOf("1")) + subject.setShouldHideOnboarding(true) + assertEquals(true, subject.userDataStream.first().shouldHideOnboarding) + + subject.setFollowedTopicIds(emptySet()) + assertEquals(false, subject.userDataStream.first().shouldHideOnboarding) + } } diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 5d33f2bfb..8f3d7ece6 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(project(":core:model")) testImplementation(project(":core:testing")) + testImplementation(project(":core:datastore-test")) implementation(libs.kotlinx.coroutines.android) diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt index 433130679..0f0931e6b 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt @@ -52,7 +52,8 @@ class NiaPreferencesDataSource @Inject constructor( DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> DarkThemeConfig.LIGHT DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK - } + }, + shouldHideOnboarding = it.shouldHideOnboarding ) } @@ -62,6 +63,7 @@ class NiaPreferencesDataSource @Inject constructor( it.copy { followedTopicIds.clear() followedTopicIds.putAll(topicIds.associateWith { true }) + updateShouldHideOnboardingIfNecessary() } } } catch (ioException: IOException) { @@ -78,6 +80,7 @@ class NiaPreferencesDataSource @Inject constructor( } else { followedTopicIds.remove(topicId) } + updateShouldHideOnboardingIfNecessary() } } } catch (ioException: IOException) { @@ -91,6 +94,7 @@ class NiaPreferencesDataSource @Inject constructor( it.copy { followedAuthorIds.clear() followedAuthorIds.putAll(authorIds.associateWith { true }) + updateShouldHideOnboardingIfNecessary() } } } catch (ioException: IOException) { @@ -107,6 +111,7 @@ class NiaPreferencesDataSource @Inject constructor( } else { followedAuthorIds.remove(authorId) } + updateShouldHideOnboardingIfNecessary() } } } catch (ioException: IOException) { @@ -188,4 +193,19 @@ class NiaPreferencesDataSource @Inject constructor( Log.e("NiaPreferences", "Failed to update user preferences", ioException) } } + + suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { + userPreferences.updateData { + it.copy { + this.shouldHideOnboarding = shouldHideOnboarding + } + } + } +} + +private fun UserPreferencesKt.Dsl.updateShouldHideOnboardingIfNecessary() { + + if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) { + shouldHideOnboarding = false + } } 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 f0eca3d32..b7d33dcaf 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 @@ -43,4 +43,6 @@ message UserPreferences { ThemeBrandProto theme_brand = 16; DarkThemeConfigProto dark_theme_config = 17; + + bool should_hide_onboarding = 18; } diff --git a/core/datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt b/core/datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt new file mode 100644 index 000000000..d5e4be841 --- /dev/null +++ b/core/datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt @@ -0,0 +1,137 @@ +/* + * 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.datastore + +import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class NiaPreferencesDataSourceTest { + private lateinit var subject: NiaPreferencesDataSource + + @get:Rule + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + @Before + fun setup() { + subject = NiaPreferencesDataSource( + tmpFolder.testUserPreferencesDataStore() + ) + } + + @Test + fun shouldHideOnboardingIsFalseByDefault() = runTest { + assertEquals(false, subject.userDataStream.first().shouldHideOnboarding) + } + + @Test + fun userShouldHideOnboardingIsTrueWhenSet() = runTest { + subject.setShouldHideOnboarding(true) + assertEquals(true, subject.userDataStream.first().shouldHideOnboarding) + } + + @Test + fun userShouldHideOnboarding_unfollowsLastAuthor_shouldHideOnboardingIsFalse() = runTest { + + // Given: user completes onboarding by selecting a single author. + subject.toggleFollowedAuthorId("1", true) + subject.setShouldHideOnboarding(true) + + // When: they unfollow that author. + subject.toggleFollowedAuthorId("1", false) + + // Then: onboarding should be shown again + assertEquals(false, subject.userDataStream.first().shouldHideOnboarding) + } + + @Test + fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest { + + // Given: user completes onboarding by selecting a single topic. + subject.toggleFollowedTopicId("1", true) + subject.setShouldHideOnboarding(true) + + // When: they unfollow that topic. + subject.toggleFollowedTopicId("1", false) + + // Then: onboarding should be shown again + assertEquals(false, subject.userDataStream.first().shouldHideOnboarding) + } + + @Test + fun userShouldHideOnboarding_unfollowsAllAuthors_shouldHideOnboardingIsFalse() = runTest { + + // Given: user completes onboarding by selecting several authors. + subject.setFollowedAuthorIds(setOf("1", "2")) + subject.setShouldHideOnboarding(true) + + // When: they unfollow those authors. + subject.setFollowedAuthorIds(emptySet()) + + // Then: onboarding should be shown again + assertEquals(false, subject.userDataStream.first().shouldHideOnboarding) + } + + @Test + fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest { + + // Given: user completes onboarding by selecting several topics. + subject.setFollowedTopicIds(setOf("1", "2")) + subject.setShouldHideOnboarding(true) + + // When: they unfollow those topics. + subject.setFollowedTopicIds(emptySet()) + + // Then: onboarding should be shown again + assertEquals(false, subject.userDataStream.first().shouldHideOnboarding) + } + + @Test + fun userShouldHideOnboarding_unfollowsAllTopicsButNotAuthors_shouldHideOnboardingIsTrue() = + runTest { + // Given: user completes onboarding by selecting several topics and authors. + subject.setFollowedTopicIds(setOf("1", "2")) + subject.setFollowedAuthorIds(setOf("3", "4")) + subject.setShouldHideOnboarding(true) + + // When: they unfollow just the topics. + subject.setFollowedTopicIds(emptySet()) + + // Then: onboarding should still be dismissed + assertEquals(true, subject.userDataStream.first().shouldHideOnboarding) + } + + @Test + fun userShouldHideOnboarding_unfollowsAllAuthorsButNotTopics_shouldHideOnboardingIsTrue() = + runTest { + // Given: user completes onboarding by selecting several topics and authors. + subject.setFollowedTopicIds(setOf("1", "2")) + subject.setFollowedAuthorIds(setOf("3", "4")) + subject.setShouldHideOnboarding(true) + + // When: they unfollow just the authors. + subject.setFollowedAuthorIds(emptySet()) + + // Then: onboarding should still be dismissed + assertEquals(true, subject.userDataStream.first().shouldHideOnboarding) + } +} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCase.kt index 80dea1898..2f1fada9d 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCase.kt +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCase.kt @@ -36,29 +36,18 @@ class GetFollowableTopicsStreamUseCase @Inject constructor( /** * Returns a list of topics with their associated followed state. * - * @param followedTopicIdsStream - the set of topic ids which are currently being followed. By - * default the followed topic ids are supplied from the user data repository, but in certain - * scenarios, such as when creating a temporary set of followed topics, you may wish to override - * this parameter to supply your own list of topic ids. @see ForYouViewModel for an example of - * this. * @param sortBy - the field used to sort the topics. Default NONE = no sorting. */ - operator fun invoke( - followedTopicIdsStream: Flow> = - userDataRepository.userDataStream.map { userdata -> - userdata.followedTopics - }, - sortBy: TopicSortField = NONE - ): Flow> { + operator fun invoke(sortBy: TopicSortField = NONE): Flow> { return combine( - followedTopicIdsStream, + userDataRepository.userDataStream, topicsRepository.getTopicsStream() - ) { followedIds, topics -> + ) { userData, topics -> val followedTopics = topics .map { topic -> FollowableTopic( topic = topic, - isFollowed = topic.id in followedIds + isFollowed = topic.id in userData.followedTopics ) } when (sortBy) { diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetPersistentSortedFollowableAuthorsStreamUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetPersistentSortedFollowableAuthorsStreamUseCase.kt deleted file mode 100644 index fda0b4728..000000000 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetPersistentSortedFollowableAuthorsStreamUseCase.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.core.domain - -import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map - -/** - * A use case which obtains a sorted list of authors with their followed state obtained from user - * data. - */ -class GetPersistentSortedFollowableAuthorsStreamUseCase @Inject constructor( - authorsRepository: AuthorsRepository, - private val userDataRepository: UserDataRepository -) { - private val getSortedFollowableAuthorsStream = - GetSortedFollowableAuthorsStreamUseCase(authorsRepository) - - /** - * Returns a list of authors with their associated followed state sorted alphabetically by name. - */ - operator fun invoke(): Flow> { - return userDataRepository.userDataStream.map { userdata -> - userdata.followedAuthors - }.flatMapLatest { - getSortedFollowableAuthorsStream(it) - } - } -} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCase.kt index e1709e4cd..498558c2f 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCase.kt +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCase.kt @@ -17,33 +17,34 @@ package com.google.samples.apps.nowinandroid.core.domain import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor import javax.inject.Inject import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine /** * A use case which obtains a list of authors sorted alphabetically by name with their followed * state. */ class GetSortedFollowableAuthorsStreamUseCase @Inject constructor( - private val authorsRepository: AuthorsRepository + private val authorsRepository: AuthorsRepository, + private val userDataRepository: UserDataRepository ) { /** * Returns a list of authors with their associated followed state sorted alphabetically by name. - * - * @param followedTopicIds - the set of topic ids which are currently being followed. */ - operator fun invoke(followedAuthorIds: Set): Flow> { - return authorsRepository.getAuthorsStream().map { authors -> - authors - .map { author -> - FollowableAuthor( - author = author, - isFollowed = author.id in followedAuthorIds - ) - } + operator fun invoke(): Flow> = + combine( + authorsRepository.getAuthorsStream(), + userDataRepository.userDataStream + ) { authors, userData -> + authors.map { author -> + FollowableAuthor( + author = author, + isFollowed = author.id in userData.followedAuthors + ) + } .sortedBy { it.author.name } } - } } diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCaseTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCaseTest.kt index c406de47b..101590605 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCaseTest.kt +++ b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsStreamUseCaseTest.kt @@ -16,7 +16,6 @@ package com.google.samples.apps.nowinandroid.core.domain -import androidx.compose.runtime.snapshotFlow import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic @@ -63,31 +62,6 @@ class GetFollowableTopicsStreamUseCaseTest { ) } - @Test - fun whenFollowedTopicIdsSupplied_differentFollowedTopicsAreReturned() = runTest { - - // Obtain a stream of followable topics, specifying a list of topic ids which are currently - // followed. - val followableTopics = useCase( - followedTopicIdsStream = snapshotFlow { setOf(testTopics[1].id) } - ) - - // Send some test topics and their followed state. - topicsRepository.sendTopics(testTopics) - userDataRepository.setFollowedTopicIds(setOf(testTopics[0].id)) - - // Check that the topic ids supplied to the use case are used for the bookmark state, not - // the topic ids in the user data repository. - assertEquals( - followableTopics.first(), - listOf( - FollowableTopic(testTopics[0], false), - FollowableTopic(testTopics[1], true), - FollowableTopic(testTopics[2], false), - ) - ) - } - @Test fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest { diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetPersistentSortedFollowableAuthorsStreamUseCaseTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetPersistentSortedFollowableAuthorsStreamUseCaseTest.kt deleted file mode 100644 index 250a5e89d..000000000 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetPersistentSortedFollowableAuthorsStreamUseCaseTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.core.domain - -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor -import com.google.samples.apps.nowinandroid.core.model.data.Author -import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository -import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository -import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test - -class GetPersistentSortedFollowableAuthorsStreamUseCaseTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - private val authorsRepository = TestAuthorsRepository() - private val userDataRepository = TestUserDataRepository() - - val useCase = GetPersistentSortedFollowableAuthorsStreamUseCase( - authorsRepository = authorsRepository, - userDataRepository = userDataRepository - ) - - @Test - fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest { - - // Obtain the stream of authors. - val followableAuthorsStream = useCase() - - // Supply some authors and their followed state in user data. - authorsRepository.sendAuthors(sampleAuthors) - userDataRepository.setFollowedAuthorIds(setOf(sampleAuthor1.id, sampleAuthor3.id)) - - // Check that the authors have been sorted, and that the followed state is correct. - assertEquals( - listOf( - FollowableAuthor(sampleAuthor2, false), - FollowableAuthor(sampleAuthor1, true), - FollowableAuthor(sampleAuthor3, true) - ), - followableAuthorsStream.first() - ) - } -} - -private val sampleAuthor1 = - Author( - id = "Author1", - name = "Mandy", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ) - -private val sampleAuthor2 = - Author( - id = "Author2", - name = "Andy", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ) - -private val sampleAuthor3 = - Author( - id = "Author3", - name = "Sandy", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ) - -private val sampleAuthors = listOf(sampleAuthor1, sampleAuthor2, sampleAuthor3) diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCaseTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCaseTest.kt index c1ae1e961..a08ca1b3a 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCaseTest.kt +++ b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetSortedFollowableAuthorsStreamUseCaseTest.kt @@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.domain import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -32,14 +33,21 @@ class GetSortedFollowableAuthorsStreamUseCaseTest { val mainDispatcherRule = MainDispatcherRule() private val authorsRepository = TestAuthorsRepository() + private val userDataRepository = TestUserDataRepository() - val useCase = GetSortedFollowableAuthorsStreamUseCase(authorsRepository) + val useCase = GetSortedFollowableAuthorsStreamUseCase( + authorsRepository = authorsRepository, + userDataRepository = userDataRepository + ) @Test fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest { + // Specify some authors which the user is following. + userDataRepository.setFollowedAuthorIds(setOf(sampleAuthor1.id)) + // Obtain the stream of authors, specifying their followed state. - val followableAuthorsStream = useCase(followedAuthorIds = setOf(sampleAuthor1.id)) + val followableAuthorsStream = useCase() // Supply some authors. authorsRepository.sendAuthors(sampleAuthors) diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt index 13f1dd737..c3941cb0f 100644 --- a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt @@ -25,4 +25,5 @@ data class UserData( val followedAuthors: Set, val themeBrand: ThemeBrand, val darkThemeConfig: DarkThemeConfig, + val shouldHideOnboarding: Boolean ) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt index 4dfd573a1..507fa1f7b 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt @@ -30,7 +30,8 @@ private val emptyUserData = UserData( followedTopics = emptySet(), followedAuthors = emptySet(), themeBrand = ThemeBrand.DEFAULT, - darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM + darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, + shouldHideOnboarding = false ) class TestUserDataRepository : UserDataRepository { @@ -90,6 +91,12 @@ class TestUserDataRepository : UserDataRepository { } } + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(shouldHideOnboarding = shouldHideOnboarding)) + } + } + /** * A test-only API to allow setting/unsetting of bookmarks. * diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt index 7a3500fbb..d4ca9180d 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt @@ -30,9 +30,9 @@ import androidx.metrics.performance.PerformanceMetricsState.Holder import kotlinx.coroutines.CoroutineScope /** - * Retrieves [PerformanceMetricsState.MetricsStateHolder] from current [LocalView] and + * Retrieves [PerformanceMetricsState.Holder] from current [LocalView] and * remembers it until the View changes. - * @see PerformanceMetricsState.getForHierarchy + * @see PerformanceMetricsState.getHolderForHierarchy */ @Composable fun rememberMetricsStateHolder(): Holder { 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 af5f07372..d89f5d91f 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 @@ -54,7 +54,7 @@ class ForYouScreenTest { BoxWithConstraints { ForYouScreen( isSyncing = false, - interestsSelectionState = ForYouInterestsSelectionUiState.Loading, + onboardingUiState = OnboardingUiState.Loading, feedState = NewsFeedUiState.Loading, onTopicCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> }, @@ -77,7 +77,7 @@ class ForYouScreenTest { BoxWithConstraints { ForYouScreen( isSyncing = true, - interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, + onboardingUiState = OnboardingUiState.NotShown, feedState = NewsFeedUiState.Success(emptyList()), onTopicCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> }, @@ -100,8 +100,8 @@ class ForYouScreenTest { BoxWithConstraints { ForYouScreen( isSyncing = false, - interestsSelectionState = - ForYouInterestsSelectionUiState.WithInterestsSelection( + onboardingUiState = + OnboardingUiState.Shown( topics = testTopics, authors = testAuthors ), @@ -149,8 +149,8 @@ class ForYouScreenTest { BoxWithConstraints { ForYouScreen( isSyncing = false, - interestsSelectionState = - ForYouInterestsSelectionUiState.WithInterestsSelection( + onboardingUiState = + OnboardingUiState.Shown( // Follow one topic topics = testTopics.mapIndexed { index, testTopic -> testTopic.copy(isFollowed = index == 1) @@ -201,8 +201,8 @@ class ForYouScreenTest { BoxWithConstraints { ForYouScreen( isSyncing = false, - interestsSelectionState = - ForYouInterestsSelectionUiState.WithInterestsSelection( + onboardingUiState = + OnboardingUiState.Shown( // Follow one topic topics = testTopics, authors = testAuthors.mapIndexed { index, testAuthor -> @@ -253,8 +253,8 @@ class ForYouScreenTest { BoxWithConstraints { ForYouScreen( isSyncing = false, - interestsSelectionState = - ForYouInterestsSelectionUiState.WithInterestsSelection( + onboardingUiState = + OnboardingUiState.Shown( topics = testTopics, authors = testAuthors ), @@ -280,7 +280,7 @@ class ForYouScreenTest { BoxWithConstraints { ForYouScreen( isSyncing = false, - interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, + onboardingUiState = OnboardingUiState.NotShown, feedState = NewsFeedUiState.Loading, onTopicCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> }, @@ -302,7 +302,7 @@ class ForYouScreenTest { composeTestRule.setContent { ForYouScreen( isSyncing = false, - interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, + onboardingUiState = OnboardingUiState.NotShown, feedState = NewsFeedUiState.Success( feed = previewNewsResources.map { SaveableNewsResource(it, false) diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/FollowedInterestsUiState.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/FollowedInterestsUiState.kt deleted file mode 100644 index 744cebbaa..000000000 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/FollowedInterestsUiState.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.feature.foryou - -/** - * A sealed hierarchy for the user's current followed interests state. - */ -sealed interface FollowedInterestsUiState { - - /** - * The current state is unknown (hasn't loaded yet) - */ - object Unknown : FollowedInterestsUiState - - /** - * The user hasn't followed any interests yet. - */ - object None : FollowedInterestsUiState - - /** - * The user has followed the given (non-empty) set of [topicIds] or [authorIds]. - */ - data class FollowedInterests( - val topicIds: Set, - val authorIds: Set - ) : FollowedInterestsUiState -} 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 bcff09fae..4651e1204 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 @@ -94,17 +94,17 @@ internal fun ForYouRoute( modifier: Modifier = Modifier, viewModel: ForYouViewModel = hiltViewModel() ) { - val interestsSelectionState by viewModel.interestsSelectionUiState.collectAsStateWithLifecycle() + val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() ForYouScreen( isSyncing = isSyncing, - interestsSelectionState = interestsSelectionState, + onboardingUiState = onboardingUiState, feedState = feedState, onTopicCheckedChanged = viewModel::updateTopicSelection, onAuthorCheckedChanged = viewModel::updateAuthorSelection, - saveFollowedTopics = viewModel::saveFollowedInterests, + saveFollowedTopics = viewModel::dismissOnboarding, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, modifier = modifier ) @@ -113,7 +113,7 @@ internal fun ForYouRoute( @Composable internal fun ForYouScreen( isSyncing: Boolean, - interestsSelectionState: ForYouInterestsSelectionUiState, + onboardingUiState: OnboardingUiState, feedState: NewsFeedUiState, onTopicCheckedChanged: (String, Boolean) -> Unit, onAuthorCheckedChanged: (String, Boolean) -> Unit, @@ -125,11 +125,11 @@ internal fun ForYouScreen( // Workaround to call Activity.reportFullyDrawn from Jetpack Compose. // This code should be called when the UI is ready for use // and relates to Time To Full Display. - val interestsLoaded = - interestsSelectionState !is ForYouInterestsSelectionUiState.Loading + val onboardingLoaded = + onboardingUiState !is OnboardingUiState.Loading val feedLoaded = feedState !is NewsFeedUiState.Loading - if (interestsLoaded && feedLoaded) { + if (onboardingLoaded && feedLoaded) { val localView = LocalView.current // We use Unit to call reportFullyDrawn only on the first recomposition, // however it will be called again if this composable goes out of scope. @@ -156,8 +156,8 @@ internal fun ForYouScreen( .testTag("forYou:feed"), state = state ) { - interestsSelection( - interestsSelectionState = interestsSelectionState, + onboarding( + onboardingUiState = onboardingUiState, onAuthorCheckedChanged = onAuthorCheckedChanged, onTopicCheckedChanged = onTopicCheckedChanged, saveFollowedTopics = saveFollowedTopics, @@ -193,7 +193,7 @@ internal fun ForYouScreen( AnimatedVisibility( visible = isSyncing || feedState is NewsFeedUiState.Loading || - interestsSelectionState is ForYouInterestsSelectionUiState.Loading + onboardingUiState is OnboardingUiState.Loading ) { val loadingContentDescription = stringResource(id = R.string.for_you_loading) Box( @@ -208,23 +208,23 @@ internal fun ForYouScreen( } /** - * An extension on [LazyListScope] defining the interests selection portion of the for you screen. - * Depending on the [interestsSelectionState], this might emit no items. + * An extension on [LazyListScope] defining the onboarding portion of the for you screen. + * Depending on the [onboardingUiState], this might emit no items. * */ -private fun LazyGridScope.interestsSelection( - interestsSelectionState: ForYouInterestsSelectionUiState, +private fun LazyGridScope.onboarding( + onboardingUiState: OnboardingUiState, onAuthorCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit, saveFollowedTopics: () -> Unit, interestsItemModifier: Modifier = Modifier ) { - when (interestsSelectionState) { - ForYouInterestsSelectionUiState.Loading, - ForYouInterestsSelectionUiState.LoadFailed, - ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit + when (onboardingUiState) { + OnboardingUiState.Loading, + OnboardingUiState.LoadFailed, + OnboardingUiState.NotShown -> Unit - is ForYouInterestsSelectionUiState.WithInterestsSelection -> { + is OnboardingUiState.Shown -> { item(span = { GridItemSpan(maxLineSpan) }) { Column(modifier = interestsItemModifier) { Text( @@ -244,14 +244,14 @@ private fun LazyGridScope.interestsSelection( style = MaterialTheme.typography.bodyMedium ) AuthorsCarousel( - authors = interestsSelectionState.authors, + authors = onboardingUiState.authors, onAuthorClick = onAuthorCheckedChanged, modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) ) TopicSelection( - interestsSelectionState, + onboardingUiState, onTopicCheckedChanged, Modifier.padding(bottom = 8.dp) ) @@ -262,7 +262,7 @@ private fun LazyGridScope.interestsSelection( ) { NiaFilledButton( onClick = saveFollowedTopics, - enabled = interestsSelectionState.canSaveInterests, + enabled = onboardingUiState.isDismissable, modifier = Modifier .padding(horizontal = 40.dp) .width(364.dp) @@ -280,7 +280,7 @@ private fun LazyGridScope.interestsSelection( @Composable private fun TopicSelection( - interestsSelectionState: ForYouInterestsSelectionUiState.WithInterestsSelection, + onboardingUiState: OnboardingUiState.Shown, onTopicCheckedChanged: (String, Boolean) -> Unit, modifier: Modifier = Modifier ) = trace("TopicSelection") { @@ -306,7 +306,7 @@ private fun TopicSelection( .heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() })) .fillMaxWidth() ) { - items(interestsSelectionState.topics) { + items(onboardingUiState.topics) { SingleTopicButton( name = it.topic.name, topicId = it.topic.id, @@ -396,7 +396,7 @@ fun ForYouScreenPopulatedFeed() { NiaTheme { ForYouScreen( isSyncing = false, - interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, + onboardingUiState = OnboardingUiState.NotShown, feedState = NewsFeedUiState.Success( feed = previewNewsResources.map { SaveableNewsResource(it, false) @@ -418,7 +418,7 @@ fun ForYouScreenOfflinePopulatedFeed() { NiaTheme { ForYouScreen( isSyncing = false, - interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, + onboardingUiState = OnboardingUiState.NotShown, feedState = NewsFeedUiState.Success( feed = previewNewsResources.map { SaveableNewsResource(it, false) @@ -440,7 +440,7 @@ fun ForYouScreenTopicSelection() { NiaTheme { ForYouScreen( isSyncing = false, - interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection( + onboardingUiState = OnboardingUiState.Shown( topics = previewTopics.map { FollowableTopic(it, false) }, authors = previewAuthors.map { FollowableAuthor(it, false) } ), @@ -465,7 +465,7 @@ fun ForYouScreenLoading() { NiaTheme { ForYouScreen( isSyncing = false, - interestsSelectionState = ForYouInterestsSelectionUiState.Loading, + onboardingUiState = OnboardingUiState.Loading, feedState = NewsFeedUiState.Loading, onTopicCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> }, @@ -483,7 +483,7 @@ fun ForYouScreenPopulatedAndLoading() { NiaTheme { ForYouScreen( isSyncing = true, - interestsSelectionState = ForYouInterestsSelectionUiState.Loading, + onboardingUiState = OnboardingUiState.Loading, feedState = NewsFeedUiState.Success( feed = previewNewsResources.map { SaveableNewsResource(it, false) 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 548e786fe..c79c9d777 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 @@ -16,14 +16,9 @@ package com.google.samples.apps.nowinandroid.feature.foryou -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi -import androidx.lifecycle.viewmodel.compose.saveable import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase @@ -31,9 +26,6 @@ import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResources import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState -import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.FollowedInterests -import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.None -import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.Unknown import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -41,56 +33,22 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -@OptIn(SavedStateHandleSaveableApi::class) @HiltViewModel class ForYouViewModel @Inject constructor( syncStatusMonitor: SyncStatusMonitor, private val userDataRepository: UserDataRepository, private val getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase, getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase, - getFollowableTopicsStream: GetFollowableTopicsStreamUseCase, - savedStateHandle: SavedStateHandle + getFollowableTopicsStream: GetFollowableTopicsStreamUseCase ) : ViewModel() { - private val followedInterestsUiState: StateFlow = - userDataRepository.userDataStream - .map { userData -> - if (userData.followedAuthors.isEmpty() && userData.followedTopics.isEmpty()) { - None - } else { - FollowedInterests( - authorIds = userData.followedAuthors, - topicIds = userData.followedTopics - ) - } - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = Unknown - ) - - /** - * The in-progress set of topics to be selected, persisted through process death with a - * [SavedStateHandle]. - */ - private var inProgressTopicSelection by savedStateHandle.saveable { - 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()) - } + private val shouldShowOnboarding: Flow = + userDataRepository.userDataStream.map { !it.shouldHideOnboarding } val isSyncing = syncStatusMonitor.isSyncing .stateIn( @@ -100,35 +58,23 @@ class ForYouViewModel @Inject constructor( ) val feedState: StateFlow = - combine( - followedInterestsUiState, - snapshotFlow { inProgressTopicSelection }, - snapshotFlow { inProgressAuthorSelection } - ) { followedInterestsUiState, inProgressTopicSelection, inProgressAuthorSelection -> - when (followedInterestsUiState) { - // If we don't know the current selection state, emit loading. - Unknown -> flowOf(NewsFeedUiState.Loading) - // If the user has followed topics, use those followed topics to populate the feed - is FollowedInterests -> { + userDataRepository.userDataStream + .map { userData -> + // If the user hasn't completed the onboarding and hasn't selected any interests + // show an empty news list to clearly demonstrate that their selections affect the + // news articles they will see. + if (!userData.shouldHideOnboarding && + userData.followedAuthors.isEmpty() && + userData.followedTopics.isEmpty() + ) { + snapshotFlow { NewsFeedUiState.Success(emptyList()) } + } else { getSaveableNewsResourcesStream( - filterTopicIds = followedInterestsUiState.topicIds, - filterAuthorIds = followedInterestsUiState.authorIds + filterTopicIds = userData.followedTopics, + filterAuthorIds = userData.followedAuthors ).mapToFeedState() } - // If the user hasn't followed interests yet, show a realtime populated feed based - // on the in-progress interests selections, if there are any. - None -> { - if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) { - flowOf(NewsFeedUiState.Success(emptyList())) - } else { - getSaveableNewsResourcesStream( - filterTopicIds = inProgressTopicSelection, - filterAuthorIds = inProgressAuthorSelection - ).mapToFeedState() - } - } } - } // Flatten the feed flows. // As the selected topics and topic state changes, this will cancel the old feed // monitoring and start the new one. @@ -139,58 +85,36 @@ class ForYouViewModel @Inject constructor( initialValue = NewsFeedUiState.Loading ) - val interestsSelectionUiState: StateFlow = + val onboardingUiState: StateFlow = combine( - followedInterestsUiState, - getFollowableTopicsStream( - followedTopicIdsStream = snapshotFlow { inProgressTopicSelection } - ), - snapshotFlow { inProgressAuthorSelection }.flatMapLatest { - getSortedFollowableAuthorsStream(it) - } - ) { followedInterestsUiState, topics, authors -> - when (followedInterestsUiState) { - Unknown -> ForYouInterestsSelectionUiState.Loading - is FollowedInterests -> ForYouInterestsSelectionUiState.NoInterestsSelection - None -> { - if (topics.isEmpty() && authors.isEmpty()) { - ForYouInterestsSelectionUiState.LoadFailed - } else { - ForYouInterestsSelectionUiState.WithInterestsSelection( - topics = topics, - authors = authors - ) - } - } + shouldShowOnboarding, + getFollowableTopicsStream(), + getSortedFollowableAuthorsStream() + ) { shouldShowOnboarding, topics, authors -> + if (shouldShowOnboarding) { + OnboardingUiState.Shown( + topics = topics, + authors = authors + ) + } else { + OnboardingUiState.NotShown } } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = ForYouInterestsSelectionUiState.Loading + initialValue = OnboardingUiState.Loading ) fun updateTopicSelection(topicId: String, isChecked: Boolean) { - withMutableSnapshot { - inProgressTopicSelection = - // Update the in-progress selection based on whether the topic id was checked - if (isChecked) { - inProgressTopicSelection + topicId - } else { - inProgressTopicSelection - topicId - } + viewModelScope.launch { + userDataRepository.toggleFollowedTopicId(topicId, isChecked) } } fun updateAuthorSelection(authorId: String, isChecked: Boolean) { - withMutableSnapshot { - inProgressAuthorSelection = - // Update the in-progress selection based on whether the author id was checked - if (isChecked) { - inProgressAuthorSelection + authorId - } else { - inProgressAuthorSelection - authorId - } + viewModelScope.launch { + userDataRepository.toggleFollowedAuthorId(authorId, isChecked) } } @@ -200,20 +124,9 @@ class ForYouViewModel @Inject constructor( } } - fun saveFollowedInterests() { - // Don't attempt to save anything if nothing is selected - if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) { - return - } - + fun dismissOnboarding() { viewModelScope.launch { - userDataRepository.setFollowedTopicIds(inProgressTopicSelection) - userDataRepository.setFollowedAuthorIds(inProgressAuthorSelection) - // Clear out the old selection, in case we return to onboarding - withMutableSnapshot { - inProgressTopicSelection = emptySet() - inProgressAuthorSelection = emptySet() - } + userDataRepository.setShouldHideOnboarding(true) } } } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouInterestsSelectionUiState.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt similarity index 59% rename from feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouInterestsSelectionUiState.kt rename to feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt index e1e0d56d0..4760a4d13 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouInterestsSelectionUiState.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt @@ -20,35 +20,35 @@ import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic /** - * A sealed hierarchy describing the interests selection state for the for you screen. + * A sealed hierarchy describing the onboarding state for the for you screen. */ -sealed interface ForYouInterestsSelectionUiState { +sealed interface OnboardingUiState { /** - * The interests selection state is loading. + * The onboarding state is loading. */ - object Loading : ForYouInterestsSelectionUiState + object Loading : OnboardingUiState /** - * The interests selection state was unable to load. + * The onboarding state was unable to load. */ - object LoadFailed : ForYouInterestsSelectionUiState + object LoadFailed : OnboardingUiState /** - * There is no interests selection state. + * There is no onboarding state. */ - object NoInterestsSelection : ForYouInterestsSelectionUiState + object NotShown : OnboardingUiState /** - * There is a interests selection state, with the given lists of topics and authors. + * There is a onboarding state, with the given lists of topics and authors. */ - data class WithInterestsSelection( + data class Shown( val topics: List, val authors: List - ) : ForYouInterestsSelectionUiState { + ) : OnboardingUiState { /** - * True if the current in-progress selection can be saved. + * True if the onboarding can be dismissed. */ - val canSaveInterests: Boolean get() = + val isDismissable: Boolean get() = topics.any { it.isFollowed } || authors.any { it.isFollowed } } } 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 4a6ce9c12..5f21f9d06 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 @@ -16,7 +16,6 @@ package com.google.samples.apps.nowinandroid.feature.foryou -import androidx.lifecycle.SavedStateHandle import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase @@ -65,7 +64,8 @@ class ForYouViewModelTest { userDataRepository = userDataRepository ) private val getSortedFollowableAuthorsStream = GetSortedFollowableAuthorsStreamUseCase( - authorsRepository = authorsRepository + authorsRepository = authorsRepository, + userDataRepository = userDataRepository ) private val getFollowableTopicsStreamUseCase = GetFollowableTopicsStreamUseCase( topicsRepository = topicsRepository, @@ -80,16 +80,15 @@ class ForYouViewModelTest { userDataRepository = userDataRepository, getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase, getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream, - getFollowableTopicsStream = getFollowableTopicsStreamUseCase, - savedStateHandle = SavedStateHandle() + getFollowableTopicsStream = getFollowableTopicsStreamUseCase ) } @Test fun stateIsInitiallyLoading() = runTest { assertEquals( - ForYouInterestsSelectionUiState.Loading, - viewModel.interestsSelectionUiState.value + OnboardingUiState.Loading, + viewModel.onboardingUiState.value ) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) } @@ -97,14 +96,14 @@ class ForYouViewModelTest { @Test fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) assertEquals( - ForYouInterestsSelectionUiState.Loading, - viewModel.interestsSelectionUiState.value + OnboardingUiState.Loading, + viewModel.onboardingUiState.value ) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) @@ -130,14 +129,14 @@ class ForYouViewModelTest { @Test fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } authorsRepository.sendAuthors(sampleAuthors) assertEquals( - ForYouInterestsSelectionUiState.Loading, - viewModel.interestsSelectionUiState.value + OnboardingUiState.Loading, + viewModel.onboardingUiState.value ) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) @@ -146,16 +145,16 @@ class ForYouViewModelTest { } @Test - fun stateIsLoadingWhenTopicsAreLoading() = runTest { + fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } userDataRepository.setFollowedTopicIds(emptySet()) assertEquals( - ForYouInterestsSelectionUiState.Loading, - viewModel.interestsSelectionUiState.value + OnboardingUiState.Loading, + viewModel.onboardingUiState.value ) assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value) @@ -164,16 +163,16 @@ class ForYouViewModelTest { } @Test - fun stateIsLoadingWhenAuthorsAreLoading() = runTest { + fun onboardingStateIsLoadingWhenAuthorsAreLoading() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } userDataRepository.setFollowedAuthorIds(emptySet()) assertEquals( - ForYouInterestsSelectionUiState.Loading, - viewModel.interestsSelectionUiState.value + OnboardingUiState.Loading, + viewModel.onboardingUiState.value ) assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value) @@ -182,9 +181,9 @@ class ForYouViewModelTest { } @Test - fun stateIsInterestsSelectionWhenNewsResourcesAreLoading() = runTest { + fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) @@ -193,7 +192,7 @@ class ForYouViewModelTest { userDataRepository.setFollowedAuthorIds(emptySet()) assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -265,7 +264,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -279,9 +278,9 @@ class ForYouViewModelTest { } @Test - fun stateIsInterestsSelectionAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest { + fun onboardingIsShownAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) @@ -291,7 +290,7 @@ class ForYouViewModelTest { newsRepository.sendNewsResources(sampleNewsResources) assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -363,7 +362,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -378,27 +377,28 @@ class ForYouViewModelTest { } @Test - fun stateIsWithoutInterestsSelectionAfterLoadingFollowedTopics() = runTest { + fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } authorsRepository.sendAuthors(sampleAuthors) userDataRepository.setFollowedAuthorIds(emptySet()) topicsRepository.sendTopics(sampleTopics) userDataRepository.setFollowedTopicIds(setOf("0", "1")) + viewModel.dismissOnboarding() assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value + OnboardingUiState.NotShown, + viewModel.onboardingUiState.value ) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) newsRepository.sendNewsResources(sampleNewsResources) assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value + OnboardingUiState.NotShown, + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -417,52 +417,10 @@ class ForYouViewModelTest { collectJob2.cancel() } - @Test - fun stateIsWithoutInterestsSelectionAfterLoadingFollowedAuthors() = runTest { - val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } - val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } - - authorsRepository.sendAuthors(sampleAuthors) - userDataRepository.setFollowedAuthorIds(setOf("0", "1")) - topicsRepository.sendTopics(sampleTopics) - userDataRepository.setFollowedTopicIds(emptySet()) - - assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value - ) - assertEquals( - NewsFeedUiState.Loading, - viewModel.feedState.value - ) - - newsRepository.sendNewsResources(sampleNewsResources) - - assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value - ) - assertEquals( - NewsFeedUiState.Success( - feed = sampleNewsResources.map { - SaveableNewsResource( - newsResource = it, - isSaved = false - ) - } - ), - viewModel.feedState.value - ) - - collectJob1.cancel() - collectJob2.cancel() - } - @Test fun topicSelectionUpdatesAfterSelectingTopic() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) @@ -472,7 +430,7 @@ class ForYouViewModelTest { newsRepository.sendNewsResources(sampleNewsResources) assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -544,7 +502,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -556,7 +514,7 @@ class ForYouViewModelTest { viewModel.updateTopicSelection("1", isChecked = true) assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -628,7 +586,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -651,9 +609,9 @@ class ForYouViewModelTest { } @Test - fun topicSelectionUpdatesAfterSelectingAuthor() = runTest { + fun authorSelectionUpdatesAfterSelectingAuthor() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) @@ -663,7 +621,7 @@ class ForYouViewModelTest { newsRepository.sendNewsResources(sampleNewsResources) assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -735,7 +693,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -747,7 +705,7 @@ class ForYouViewModelTest { viewModel.updateAuthorSelection("1", isChecked = true) assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -819,7 +777,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -844,7 +802,7 @@ class ForYouViewModelTest { @Test fun topicSelectionUpdatesAfterUnselectingTopic() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) @@ -857,7 +815,7 @@ class ForYouViewModelTest { advanceUntilIdle() assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -929,7 +887,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -943,9 +901,9 @@ class ForYouViewModelTest { } @Test - fun topicSelectionUpdatesAfterUnselectingAuthor() = runTest { + fun authorSelectionUpdatesAfterUnselectingAuthor() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) @@ -958,7 +916,7 @@ class ForYouViewModelTest { assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( + OnboardingUiState.Shown( topics = listOf( FollowableTopic( topic = Topic( @@ -1030,331 +988,7 @@ class ForYouViewModelTest { ) ), ), - viewModel.interestsSelectionUiState.value - ) - assertEquals( - NewsFeedUiState.Success( - feed = emptyList() - ), - viewModel.feedState.value - ) - - collectJob1.cancel() - collectJob2.cancel() - } - - @Test - fun topicSelectionUpdatesAfterSavingTopicsOnly() = runTest { - val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } - val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } - - topicsRepository.sendTopics(sampleTopics) - userDataRepository.setFollowedTopicIds(emptySet()) - authorsRepository.sendAuthors(sampleAuthors) - userDataRepository.setFollowedAuthorIds(emptySet()) - newsRepository.sendNewsResources(sampleNewsResources) - viewModel.updateTopicSelection("1", isChecked = true) - - viewModel.saveFollowedInterests() - - assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value - ) - assertEquals( - NewsFeedUiState.Success( - feed = listOf( - SaveableNewsResource( - newsResource = sampleNewsResources[1], - isSaved = false, - ), - SaveableNewsResource( - newsResource = sampleNewsResources[2], - isSaved = false, - ) - ) - ), - viewModel.feedState.value - ) - assertEquals(setOf("1"), userDataRepository.getCurrentFollowedTopics()) - assertEquals(emptySet(), userDataRepository.getCurrentFollowedAuthors()) - - collectJob1.cancel() - collectJob2.cancel() - } - - @Test - fun topicSelectionUpdatesAfterSavingAuthorsOnly() = runTest { - val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } - val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } - - topicsRepository.sendTopics(sampleTopics) - userDataRepository.setFollowedTopicIds(emptySet()) - authorsRepository.sendAuthors(sampleAuthors) - userDataRepository.setFollowedAuthorIds(emptySet()) - newsRepository.sendNewsResources(sampleNewsResources) - viewModel.updateAuthorSelection("0", isChecked = true) - - viewModel.saveFollowedInterests() - - assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value - ) - assertEquals( - NewsFeedUiState.Success( - feed = listOf( - SaveableNewsResource( - newsResource = sampleNewsResources[0], - isSaved = false - ), - ) - ), - viewModel.feedState.value - ) - assertEquals(emptySet(), userDataRepository.getCurrentFollowedTopics()) - assertEquals(setOf("0"), userDataRepository.getCurrentFollowedAuthors()) - - collectJob1.cancel() - collectJob2.cancel() - } - - @Test - fun topicSelectionUpdatesAfterSavingAuthorsAndTopics() = runTest { - val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } - val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } - - topicsRepository.sendTopics(sampleTopics) - userDataRepository.setFollowedTopicIds(emptySet()) - authorsRepository.sendAuthors(sampleAuthors) - userDataRepository.setFollowedAuthorIds(emptySet()) - newsRepository.sendNewsResources(sampleNewsResources) - viewModel.updateAuthorSelection("1", isChecked = true) - viewModel.updateTopicSelection("1", isChecked = true) - - viewModel.saveFollowedInterests() - - assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value - ) - assertEquals( - NewsFeedUiState.Success( - feed = listOf( - SaveableNewsResource( - newsResource = sampleNewsResources[1], - isSaved = false - ), - SaveableNewsResource( - newsResource = sampleNewsResources[2], - isSaved = false - ) - ) - ), - viewModel.feedState.value - ) - assertEquals(setOf("1"), userDataRepository.getCurrentFollowedTopics()) - assertEquals(setOf("1"), userDataRepository.getCurrentFollowedAuthors()) - - collectJob1.cancel() - collectJob2.cancel() - } - - @Test - fun topicSelectionIsResetAfterSavingTopicsAndRemovingThem() = runTest { - val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } - val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } - - topicsRepository.sendTopics(sampleTopics) - userDataRepository.setFollowedTopicIds(emptySet()) - authorsRepository.sendAuthors(sampleAuthors) - userDataRepository.setFollowedAuthorIds(emptySet()) - newsRepository.sendNewsResources(sampleNewsResources) - viewModel.updateTopicSelection("1", isChecked = true) - viewModel.saveFollowedInterests() - - userDataRepository.setFollowedTopicIds(emptySet()) - - assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( - 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 = "", - bio = "", - ), - isFollowed = false - ), - FollowableAuthor( - author = Author( - id = "1", - name = "Android Dev 2", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ), - isFollowed = false - ), - FollowableAuthor( - author = Author( - id = "2", - name = "Android Dev 3", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ), - isFollowed = false - ) - ) - ), - viewModel.interestsSelectionUiState.value - ) - assertEquals( - NewsFeedUiState.Success( - feed = emptyList() - ), - viewModel.feedState.value - ) - - collectJob1.cancel() - collectJob2.cancel() - } - - @Test - fun authorSelectionIsResetAfterSavingAuthorsAndRemovingThem() = runTest { - val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } - val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } - - topicsRepository.sendTopics(sampleTopics) - userDataRepository.setFollowedTopicIds(emptySet()) - authorsRepository.sendAuthors(sampleAuthors) - userDataRepository.setFollowedAuthorIds(emptySet()) - newsRepository.sendNewsResources(sampleNewsResources) - viewModel.updateAuthorSelection("1", isChecked = true) - viewModel.saveFollowedInterests() - - userDataRepository.setFollowedAuthorIds(emptySet()) - - assertEquals( - ForYouInterestsSelectionUiState.WithInterestsSelection( - 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 = "", - bio = "", - ), - isFollowed = false - ), - FollowableAuthor( - author = Author( - id = "1", - name = "Android Dev 2", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ), - isFollowed = false - ), - FollowableAuthor( - author = Author( - id = "2", - name = "Android Dev 3", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ), - isFollowed = false - ) - ) - ), - viewModel.interestsSelectionUiState.value + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( @@ -1370,19 +1004,20 @@ class ForYouViewModelTest { @Test fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest { val collectJob1 = - launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } + launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } topicsRepository.sendTopics(sampleTopics) userDataRepository.setFollowedTopicIds(setOf("1")) authorsRepository.sendAuthors(sampleAuthors) userDataRepository.setFollowedAuthorIds(setOf("1")) + userDataRepository.setShouldHideOnboarding(true) newsRepository.sendNewsResources(sampleNewsResources) viewModel.updateNewsResourceSaved("2", true) assertEquals( - ForYouInterestsSelectionUiState.NoInterestsSelection, - viewModel.interestsSelectionUiState.value + OnboardingUiState.NotShown, + viewModel.onboardingUiState.value ) assertEquals( NewsFeedUiState.Success( diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt index dcf46caf2..01d8b0d6e 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetPersistentSortedFollowableAuthorsStreamUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase import com.google.samples.apps.nowinandroid.core.domain.TopicSortField import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic @@ -39,7 +39,7 @@ import kotlinx.coroutines.launch class InterestsViewModel @Inject constructor( val userDataRepository: UserDataRepository, getFollowableTopicsStream: GetFollowableTopicsStreamUseCase, - getPersistentSortedFollowableAuthorsStream: GetPersistentSortedFollowableAuthorsStreamUseCase + getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase ) : ViewModel() { private val _tabState = MutableStateFlow( @@ -51,7 +51,7 @@ class InterestsViewModel @Inject constructor( val tabState: StateFlow = _tabState.asStateFlow() val uiState: StateFlow = combine( - getPersistentSortedFollowableAuthorsStream(), + getSortedFollowableAuthorsStream(), getFollowableTopicsStream(sortBy = TopicSortField.NAME), InterestsUiState::Interests ).stateIn( diff --git a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt index 862faec6b..6ecf3930a 100644 --- a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt +++ b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.interests import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetPersistentSortedFollowableAuthorsStreamUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Author @@ -53,8 +53,8 @@ class InterestsViewModelTest { topicsRepository = topicsRepository, userDataRepository = userDataRepository ) - private val getPersistentSortedFollowableAuthorsStream = - GetPersistentSortedFollowableAuthorsStreamUseCase( + private val getSortedFollowableAuthorsStream = + GetSortedFollowableAuthorsStreamUseCase( authorsRepository = authorsRepository, userDataRepository = userDataRepository ) @@ -65,7 +65,7 @@ class InterestsViewModelTest { viewModel = InterestsViewModel( userDataRepository = userDataRepository, getFollowableTopicsStream = getFollowableTopicsStreamUseCase, - getPersistentSortedFollowableAuthorsStream = getPersistentSortedFollowableAuthorsStream + getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dafd53947..8d13d36d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ androidxComposeRuntimeTracing = "1.0.0-alpha01" androidxCore = "1.9.0" androidxCoreSplashscreen = "1.0.0" androidxDataStore = "1.0.0" -androidxEspresso = "3.4.0" +androidxEspresso = "3.5.0" androidxHiltNavigationCompose = "1.0.0" androidxLifecycle = "2.6.0-alpha03" androidxMacroBenchmark = "1.1.0" @@ -19,10 +19,10 @@ androidxMetrics = "1.0.0-alpha03" androidxProfileinstaller = "1.2.0" androidxStartup = "1.1.1" androidxWindowManager = "1.0.0" -androidxTestCore = "1.5.0-rc01" -androidxTestExt = "1.1.3" -androidxTestRunner = "1.4.0" -androidxTestRules = "1.4.0" +androidxTestCore = "1.5.0" +androidxTestExt = "1.1.4" +androidxTestRunner = "1.5.1" +androidxTestRules = "1.5.0" androidxTracing = "1.1.0" androidxUiAutomator = "2.2.0" androidxWork = "2.7.1" @@ -128,4 +128,4 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } -secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } \ No newline at end of file +secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }