Refactor onboarding to save interests to user data

Change-Id: I97772a8b65e1e94a95187ebb7c514104af2d3a08
pull/406/head
Don Turner 2 years ago
parent 10447f2dfa
commit 6654403498

@ -50,4 +50,7 @@ class OfflineFirstUserDataRepository @Inject constructor(
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) =
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
override suspend fun setHasDismissedOnboarding(hasDismissedOnboarding: Boolean) =
niaPreferencesDataSource.setHasDismissedOnboarding(hasDismissedOnboarding)
} }

@ -62,4 +62,9 @@ interface UserDataRepository {
* Sets the desired dark theme config. * Sets the desired dark theme config.
*/ */
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
/**
* Sets whether the user has completed the onboarding process.
*/
suspend fun setHasDismissedOnboarding(hasDismissedOnboarding: Boolean)
} }

@ -63,4 +63,8 @@ class FakeUserDataRepository @Inject constructor(
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
} }
override suspend fun setHasDismissedOnboarding(hasDismissedOnboarding: Boolean) {
niaPreferencesDataSource.setHasDismissedOnboarding(hasDismissedOnboarding)
}
} }

@ -58,7 +58,8 @@ class OfflineFirstUserDataRepositoryTest {
followedTopics = emptySet(), followedTopics = emptySet(),
followedAuthors = emptySet(), followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
hasDismissedOnboarding = false
), ),
subject.userDataStream.first() subject.userDataStream.first()
) )
@ -187,4 +188,15 @@ class OfflineFirstUserDataRepositoryTest {
.first() .first()
) )
} }
@Test
fun whenUserCompletesOnboarding_thenRemovesAllInterests_hasDismissedOnboardingIsFalse() =
runTest {
subject.setFollowedTopicIds(setOf("1"))
subject.setHasDismissedOnboarding(true)
assertEquals(true, subject.userDataStream.first().hasDismissedOnboarding)
subject.setFollowedTopicIds(emptySet())
assertEquals(false, subject.userDataStream.first().hasDismissedOnboarding)
}
} }

@ -59,6 +59,7 @@ dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
testImplementation(project(":core:testing")) testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)

@ -52,7 +52,8 @@ class NiaPreferencesDataSource @Inject constructor(
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT ->
DarkThemeConfig.LIGHT DarkThemeConfig.LIGHT
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK
} },
hasDismissedOnboarding = it.hasDismissedOnboarding
) )
} }
@ -62,6 +63,7 @@ class NiaPreferencesDataSource @Inject constructor(
it.copy { it.copy {
followedTopicIds.clear() followedTopicIds.clear()
followedTopicIds.putAll(topicIds.associateWith { true }) followedTopicIds.putAll(topicIds.associateWith { true })
updateHasDismissedOnboardingIfNecessary()
} }
} }
} catch (ioException: IOException) { } catch (ioException: IOException) {
@ -78,6 +80,7 @@ class NiaPreferencesDataSource @Inject constructor(
} else { } else {
followedTopicIds.remove(topicId) followedTopicIds.remove(topicId)
} }
updateHasDismissedOnboardingIfNecessary()
} }
} }
} catch (ioException: IOException) { } catch (ioException: IOException) {
@ -91,6 +94,7 @@ class NiaPreferencesDataSource @Inject constructor(
it.copy { it.copy {
followedAuthorIds.clear() followedAuthorIds.clear()
followedAuthorIds.putAll(authorIds.associateWith { true }) followedAuthorIds.putAll(authorIds.associateWith { true })
updateHasDismissedOnboardingIfNecessary()
} }
} }
} catch (ioException: IOException) { } catch (ioException: IOException) {
@ -107,6 +111,7 @@ class NiaPreferencesDataSource @Inject constructor(
} else { } else {
followedAuthorIds.remove(authorId) followedAuthorIds.remove(authorId)
} }
updateHasDismissedOnboardingIfNecessary()
} }
} }
} catch (ioException: IOException) { } catch (ioException: IOException) {
@ -188,4 +193,19 @@ class NiaPreferencesDataSource @Inject constructor(
Log.e("NiaPreferences", "Failed to update user preferences", ioException) Log.e("NiaPreferences", "Failed to update user preferences", ioException)
} }
} }
suspend fun setHasDismissedOnboarding(hasDismissedOnboarding: Boolean) {
userPreferences.updateData {
it.copy {
this.hasDismissedOnboarding = hasDismissedOnboarding
}
}
}
}
fun UserPreferencesKt.Dsl.updateHasDismissedOnboardingIfNecessary() {
if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) {
hasDismissedOnboarding = false
}
} }

@ -43,4 +43,9 @@ message UserPreferences {
ThemeBrandProto theme_brand = 16; ThemeBrandProto theme_brand = 16;
DarkThemeConfigProto dark_theme_config = 17; DarkThemeConfigProto dark_theme_config = 17;
// Note that proto3 only allows a default value of `false` for boolean types. This means that
// whilst the name `should_show_onboarding` would be preferable here we are forced to choose a
// name which works with this default value constraint.
bool has_dismissed_onboarding = 18;
} }

@ -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 hasDismissedOnboardingIsFalseByDefault() = runTest {
assertEquals(false, subject.userDataStream.first().hasDismissedOnboarding)
}
@Test
fun userHasDismissedOnboardingIsTrueWhenSet() = runTest {
subject.setHasDismissedOnboarding(true)
assertEquals(true, subject.userDataStream.first().hasDismissedOnboarding)
}
@Test
fun userHasDismissedOnboarding_unfollowsLastAuthor_hasDismissedOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting a single author.
subject.toggleFollowedAuthorId("1", true)
subject.setHasDismissedOnboarding(true)
// When: they unfollow that author.
subject.toggleFollowedAuthorId("1", false)
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().hasDismissedOnboarding)
}
@Test
fun userHasDismissedOnboarding_unfollowsLastTopic_hasDismissedOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting a single topic.
subject.toggleFollowedTopicId("1", true)
subject.setHasDismissedOnboarding(true)
// When: they unfollow that topic.
subject.toggleFollowedTopicId("1", false)
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().hasDismissedOnboarding)
}
@Test
fun userHasDismissedOnboarding_unfollowsAllAuthors_hasDismissedOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting several authors.
subject.setFollowedAuthorIds(setOf("1", "2"))
subject.setHasDismissedOnboarding(true)
// When: they unfollow those authors.
subject.setFollowedAuthorIds(emptySet())
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().hasDismissedOnboarding)
}
@Test
fun userHasDismissedOnboarding_unfollowsAllTopics_hasDismissedOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting several topics.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setHasDismissedOnboarding(true)
// When: they unfollow those topics.
subject.setFollowedTopicIds(emptySet())
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().hasDismissedOnboarding)
}
@Test
fun userHasDismissedOnboarding_unfollowsAllTopicsButNotAuthors_hasDismissedOnboardingIsTrue() =
runTest {
// Given: user completes onboarding by selecting several topics and authors.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setFollowedAuthorIds(setOf("3", "4"))
subject.setHasDismissedOnboarding(true)
// When: they unfollow just the topics.
subject.setFollowedTopicIds(emptySet())
// Then: onboarding should still be dismissed
assertEquals(true, subject.userDataStream.first().hasDismissedOnboarding)
}
@Test
fun userHasDismissedOnboarding_unfollowsAllAuthorsButNotTopics_hasDismissedOnboardingIsTrue() =
runTest {
// Given: user completes onboarding by selecting several topics and authors.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setFollowedAuthorIds(setOf("3", "4"))
subject.setHasDismissedOnboarding(true)
// When: they unfollow just the authors.
subject.setFollowedAuthorIds(emptySet())
// Then: onboarding should still be dismissed
assertEquals(true, subject.userDataStream.first().hasDismissedOnboarding)
}
}

@ -36,29 +36,18 @@ class GetFollowableTopicsStreamUseCase @Inject constructor(
/** /**
* Returns a list of topics with their associated followed state. * 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. * @param sortBy - the field used to sort the topics. Default NONE = no sorting.
*/ */
operator fun invoke( operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> {
followedTopicIdsStream: Flow<Set<String>> =
userDataRepository.userDataStream.map { userdata ->
userdata.followedTopics
},
sortBy: TopicSortField = NONE
): Flow<List<FollowableTopic>> {
return combine( return combine(
followedTopicIdsStream, userDataRepository.userDataStream,
topicsRepository.getTopicsStream() topicsRepository.getTopicsStream()
) { followedIds, topics -> ) { userData, topics ->
val followedTopics = topics val followedTopics = topics
.map { topic -> .map { topic ->
FollowableTopic( FollowableTopic(
topic = topic, topic = topic,
isFollowed = topic.id in followedIds isFollowed = topic.id in userData.followedTopics
) )
} }
when (sortBy) { when (sortBy) {

@ -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<List<FollowableAuthor>> {
return userDataRepository.userDataStream.map { userdata ->
userdata.followedAuthors
}.flatMapLatest {
getSortedFollowableAuthorsStream(it)
}
}
}

@ -17,33 +17,34 @@
package com.google.samples.apps.nowinandroid.core.domain 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.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow 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 * A use case which obtains a list of authors sorted alphabetically by name with their followed
* state. * state.
*/ */
class GetSortedFollowableAuthorsStreamUseCase @Inject constructor( 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. * 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<String>): Flow<List<FollowableAuthor>> { operator fun invoke(): Flow<List<FollowableAuthor>> =
return authorsRepository.getAuthorsStream().map { authors -> combine(
authors authorsRepository.getAuthorsStream(),
.map { author -> userDataRepository.userDataStream
) { authors, userData ->
authors.map { author ->
FollowableAuthor( FollowableAuthor(
author = author, author = author,
isFollowed = author.id in followedAuthorIds isFollowed = author.id in userData.followedAuthors
) )
} }
.sortedBy { it.author.name } .sortedBy { it.author.name }
} }
}
} }

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.domain 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.TopicSortField.NAME
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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 @Test
fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest { fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest {

@ -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)

@ -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.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository 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 com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -32,14 +33,21 @@ class GetSortedFollowableAuthorsStreamUseCaseTest {
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val authorsRepository = TestAuthorsRepository() private val authorsRepository = TestAuthorsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetSortedFollowableAuthorsStreamUseCase(authorsRepository) val useCase = GetSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
@Test @Test
fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest { 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. // Obtain the stream of authors, specifying their followed state.
val followableAuthorsStream = useCase(followedAuthorIds = setOf(sampleAuthor1.id)) val followableAuthorsStream = useCase()
// Supply some authors. // Supply some authors.
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)

@ -25,4 +25,5 @@ data class UserData(
val followedAuthors: Set<String>, val followedAuthors: Set<String>,
val themeBrand: ThemeBrand, val themeBrand: ThemeBrand,
val darkThemeConfig: DarkThemeConfig, val darkThemeConfig: DarkThemeConfig,
val hasDismissedOnboarding: Boolean
) )

@ -30,7 +30,8 @@ private val emptyUserData = UserData(
followedTopics = emptySet(), followedTopics = emptySet(),
followedAuthors = emptySet(), followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
hasDismissedOnboarding = false
) )
class TestUserDataRepository : UserDataRepository { class TestUserDataRepository : UserDataRepository {
@ -90,6 +91,12 @@ class TestUserDataRepository : UserDataRepository {
} }
} }
override suspend fun setHasDismissedOnboarding(hasDismissedOnboarding: Boolean) {
currentUserData.let { current ->
_userData.tryEmit(current.copy(hasDismissedOnboarding = hasDismissedOnboarding))
}
}
/** /**
* A test-only API to allow setting/unsetting of bookmarks. * A test-only API to allow setting/unsetting of bookmarks.
* *

@ -54,7 +54,7 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -77,7 +77,7 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = true, isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()), feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -100,8 +100,8 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = onboardingUiState =
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = testTopics, topics = testTopics,
authors = testAuthors authors = testAuthors
), ),
@ -149,8 +149,8 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = onboardingUiState =
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
// Follow one topic // Follow one topic
topics = testTopics.mapIndexed { index, testTopic -> topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1) testTopic.copy(isFollowed = index == 1)
@ -201,8 +201,8 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = onboardingUiState =
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
// Follow one topic // Follow one topic
topics = testTopics, topics = testTopics,
authors = testAuthors.mapIndexed { index, testAuthor -> authors = testAuthors.mapIndexed { index, testAuthor ->
@ -253,8 +253,8 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = onboardingUiState =
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = testTopics, topics = testTopics,
authors = testAuthors authors = testAuthors
), ),
@ -280,7 +280,7 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -302,7 +302,7 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)

@ -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<String>,
val authorIds: Set<String>
) : FollowedInterestsUiState
}

@ -94,17 +94,17 @@ internal fun ForYouRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel() viewModel: ForYouViewModel = hiltViewModel()
) { ) {
val interestsSelectionState by viewModel.interestsSelectionUiState.collectAsStateWithLifecycle() val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
ForYouScreen( ForYouScreen(
isSyncing = isSyncing, isSyncing = isSyncing,
interestsSelectionState = interestsSelectionState, onboardingUiState = onboardingUiState,
feedState = feedState, feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection, onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests, saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier modifier = modifier
) )
@ -113,7 +113,7 @@ internal fun ForYouRoute(
@Composable @Composable
internal fun ForYouScreen( internal fun ForYouScreen(
isSyncing: Boolean, isSyncing: Boolean,
interestsSelectionState: ForYouInterestsSelectionUiState, onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
onAuthorCheckedChanged: (String, Boolean) -> Unit, onAuthorCheckedChanged: (String, Boolean) -> Unit,
@ -125,11 +125,11 @@ internal fun ForYouScreen(
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose. // Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// This code should be called when the UI is ready for use // This code should be called when the UI is ready for use
// and relates to Time To Full Display. // and relates to Time To Full Display.
val interestsLoaded = val onboardingLoaded =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading onboardingUiState !is OnboardingUiState.Loading
val feedLoaded = feedState !is NewsFeedUiState.Loading val feedLoaded = feedState !is NewsFeedUiState.Loading
if (interestsLoaded && feedLoaded) { if (onboardingLoaded && feedLoaded) {
val localView = LocalView.current val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition, // We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope. // however it will be called again if this composable goes out of scope.
@ -156,8 +156,8 @@ internal fun ForYouScreen(
.testTag("forYou:feed"), .testTag("forYou:feed"),
state = state state = state
) { ) {
interestsSelection( onboarding(
interestsSelectionState = interestsSelectionState, onboardingUiState = onboardingUiState,
onAuthorCheckedChanged = onAuthorCheckedChanged, onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged, onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics, saveFollowedTopics = saveFollowedTopics,
@ -193,7 +193,7 @@ internal fun ForYouScreen(
AnimatedVisibility( AnimatedVisibility(
visible = isSyncing || visible = isSyncing ||
feedState is NewsFeedUiState.Loading || feedState is NewsFeedUiState.Loading ||
interestsSelectionState is ForYouInterestsSelectionUiState.Loading onboardingUiState is OnboardingUiState.Loading
) { ) {
val loadingContentDescription = stringResource(id = R.string.for_you_loading) val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box( Box(
@ -208,23 +208,23 @@ internal fun ForYouScreen(
} }
/** /**
* An extension on [LazyListScope] defining the interests selection portion of the for you screen. * An extension on [LazyListScope] defining the onboarding portion of the for you screen.
* Depending on the [interestsSelectionState], this might emit no items. * Depending on the [onboardingUiState], this might emit no items.
* *
*/ */
private fun LazyGridScope.interestsSelection( private fun LazyGridScope.onboarding(
interestsSelectionState: ForYouInterestsSelectionUiState, onboardingUiState: OnboardingUiState,
onAuthorCheckedChanged: (String, Boolean) -> Unit, onAuthorCheckedChanged: (String, Boolean) -> Unit,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
interestsItemModifier: Modifier = Modifier interestsItemModifier: Modifier = Modifier
) { ) {
when (interestsSelectionState) { when (onboardingUiState) {
ForYouInterestsSelectionUiState.Loading, OnboardingUiState.Loading,
ForYouInterestsSelectionUiState.LoadFailed, OnboardingUiState.LoadFailed,
ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit OnboardingUiState.NotShown -> Unit
is ForYouInterestsSelectionUiState.WithInterestsSelection -> { is OnboardingUiState.Shown -> {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Column(modifier = interestsItemModifier) { Column(modifier = interestsItemModifier) {
Text( Text(
@ -244,14 +244,14 @@ private fun LazyGridScope.interestsSelection(
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
AuthorsCarousel( AuthorsCarousel(
authors = interestsSelectionState.authors, authors = onboardingUiState.authors,
onAuthorClick = onAuthorCheckedChanged, onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) )
TopicSelection( TopicSelection(
interestsSelectionState, onboardingUiState,
onTopicCheckedChanged, onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp) Modifier.padding(bottom = 8.dp)
) )
@ -262,7 +262,7 @@ private fun LazyGridScope.interestsSelection(
) { ) {
NiaFilledButton( NiaFilledButton(
onClick = saveFollowedTopics, onClick = saveFollowedTopics,
enabled = interestsSelectionState.canSaveInterests, enabled = onboardingUiState.isDismissable,
modifier = Modifier modifier = Modifier
.padding(horizontal = 40.dp) .padding(horizontal = 40.dp)
.width(364.dp) .width(364.dp)
@ -280,7 +280,7 @@ private fun LazyGridScope.interestsSelection(
@Composable @Composable
private fun TopicSelection( private fun TopicSelection(
interestsSelectionState: ForYouInterestsSelectionUiState.WithInterestsSelection, onboardingUiState: OnboardingUiState.Shown,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) = trace("TopicSelection") { ) = trace("TopicSelection") {
@ -306,7 +306,7 @@ private fun TopicSelection(
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() })) .heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth() .fillMaxWidth()
) { ) {
items(interestsSelectionState.topics) { items(onboardingUiState.topics) {
SingleTopicButton( SingleTopicButton(
name = it.topic.name, name = it.topic.name,
topicId = it.topic.id, topicId = it.topic.id,
@ -396,7 +396,7 @@ fun ForYouScreenPopulatedFeed() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)
@ -418,7 +418,7 @@ fun ForYouScreenOfflinePopulatedFeed() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)
@ -440,7 +440,7 @@ fun ForYouScreenTopicSelection() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection( onboardingUiState = OnboardingUiState.Shown(
topics = previewTopics.map { FollowableTopic(it, false) }, topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) } authors = previewAuthors.map { FollowableAuthor(it, false) }
), ),
@ -465,7 +465,7 @@ fun ForYouScreenLoading() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -483,7 +483,7 @@ fun ForYouScreenPopulatedAndLoading() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = true, isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)

@ -16,14 +16,9 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase 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.GetSortedFollowableAuthorsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource 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.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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -41,56 +33,22 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel @HiltViewModel
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor, syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
private val getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase, private val getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase,
getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase, getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase,
getFollowableTopicsStream: GetFollowableTopicsStreamUseCase, getFollowableTopicsStream: GetFollowableTopicsStreamUseCase
savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
private val followedInterestsUiState: StateFlow<FollowedInterestsUiState> = private val shouldShowOnboarding: Flow<Boolean> =
userDataRepository.userDataStream userDataRepository.userDataStream.map { !it.hasDismissedOnboarding }
.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<Set<String>>(emptySet())
}
/**
* The in-progress set of authors to be selected, persisted through process death with a
* [SavedStateHandle].
*/
private var inProgressAuthorSelection by savedStateHandle.saveable {
mutableStateOf<Set<String>>(emptySet())
}
val isSyncing = syncStatusMonitor.isSyncing val isSyncing = syncStatusMonitor.isSyncing
.stateIn( .stateIn(
@ -100,35 +58,23 @@ class ForYouViewModel @Inject constructor(
) )
val feedState: StateFlow<NewsFeedUiState> = val feedState: StateFlow<NewsFeedUiState> =
combine( userDataRepository.userDataStream
followedInterestsUiState, .map { userData ->
snapshotFlow { inProgressTopicSelection }, // If the user hasn't completed the onboarding and hasn't selected any interests
snapshotFlow { inProgressAuthorSelection } // show an empty news list to clearly demonstrate that their selections affect the
) { followedInterestsUiState, inProgressTopicSelection, inProgressAuthorSelection -> // news articles they will see.
when (followedInterestsUiState) { if (!userData.hasDismissedOnboarding &&
// If we don't know the current selection state, emit loading. userData.followedAuthors.isEmpty() &&
Unknown -> flowOf<NewsFeedUiState>(NewsFeedUiState.Loading) userData.followedTopics.isEmpty()
// If the user has followed topics, use those followed topics to populate the feed ) {
is FollowedInterests -> { snapshotFlow { NewsFeedUiState.Success(emptyList()) }
getSaveableNewsResourcesStream(
filterTopicIds = followedInterestsUiState.topicIds,
filterAuthorIds = followedInterestsUiState.authorIds
).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>(NewsFeedUiState.Success(emptyList()))
} else { } else {
getSaveableNewsResourcesStream( getSaveableNewsResourcesStream(
filterTopicIds = inProgressTopicSelection, filterTopicIds = userData.followedTopics,
filterAuthorIds = inProgressAuthorSelection filterAuthorIds = userData.followedAuthors
).mapToFeedState() ).mapToFeedState()
} }
} }
}
}
// Flatten the feed flows. // Flatten the feed flows.
// As the selected topics and topic state changes, this will cancel the old feed // As the selected topics and topic state changes, this will cancel the old feed
// monitoring and start the new one. // monitoring and start the new one.
@ -139,58 +85,36 @@ class ForYouViewModel @Inject constructor(
initialValue = NewsFeedUiState.Loading initialValue = NewsFeedUiState.Loading
) )
val interestsSelectionUiState: StateFlow<ForYouInterestsSelectionUiState> = val onboardingUiState: StateFlow<OnboardingUiState> =
combine( combine(
followedInterestsUiState, shouldShowOnboarding,
getFollowableTopicsStream( getFollowableTopicsStream(),
followedTopicIdsStream = snapshotFlow { inProgressTopicSelection } getSortedFollowableAuthorsStream()
), ) { shouldShowOnboarding, topics, authors ->
snapshotFlow { inProgressAuthorSelection }.flatMapLatest { if (shouldShowOnboarding) {
getSortedFollowableAuthorsStream(it) OnboardingUiState.Shown(
}
) { 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, topics = topics,
authors = authors authors = authors
) )
} } else {
} OnboardingUiState.NotShown
} }
} }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = ForYouInterestsSelectionUiState.Loading initialValue = OnboardingUiState.Loading
) )
fun updateTopicSelection(topicId: String, isChecked: Boolean) { fun updateTopicSelection(topicId: String, isChecked: Boolean) {
withMutableSnapshot { viewModelScope.launch {
inProgressTopicSelection = userDataRepository.toggleFollowedTopicId(topicId, isChecked)
// Update the in-progress selection based on whether the topic id was checked
if (isChecked) {
inProgressTopicSelection + topicId
} else {
inProgressTopicSelection - topicId
}
} }
} }
fun updateAuthorSelection(authorId: String, isChecked: Boolean) { fun updateAuthorSelection(authorId: String, isChecked: Boolean) {
withMutableSnapshot { viewModelScope.launch {
inProgressAuthorSelection = userDataRepository.toggleFollowedAuthorId(authorId, isChecked)
// Update the in-progress selection based on whether the author id was checked
if (isChecked) {
inProgressAuthorSelection + authorId
} else {
inProgressAuthorSelection - authorId
}
} }
} }
@ -200,20 +124,9 @@ class ForYouViewModel @Inject constructor(
} }
} }
fun saveFollowedInterests() { fun dismissOnboarding() {
// Don't attempt to save anything if nothing is selected
if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
return
}
viewModelScope.launch { viewModelScope.launch {
userDataRepository.setFollowedTopicIds(inProgressTopicSelection) userDataRepository.setHasDismissedOnboarding(true)
userDataRepository.setFollowedAuthorIds(inProgressAuthorSelection)
// Clear out the old selection, in case we return to onboarding
withMutableSnapshot {
inProgressTopicSelection = emptySet()
inProgressAuthorSelection = emptySet()
}
} }
} }
} }

@ -20,35 +20,35 @@ 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.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<FollowableTopic>, val topics: List<FollowableTopic>,
val authors: List<FollowableAuthor> val authors: List<FollowableAuthor>
) : 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 } topics.any { it.isFollowed } || authors.any { it.isFollowed }
} }
} }

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.foryou 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.GetFollowableTopicsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase
@ -65,7 +64,8 @@ class ForYouViewModelTest {
userDataRepository = userDataRepository userDataRepository = userDataRepository
) )
private val getSortedFollowableAuthorsStream = GetSortedFollowableAuthorsStreamUseCase( private val getSortedFollowableAuthorsStream = GetSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository authorsRepository = authorsRepository,
userDataRepository = userDataRepository
) )
private val getFollowableTopicsStreamUseCase = GetFollowableTopicsStreamUseCase( private val getFollowableTopicsStreamUseCase = GetFollowableTopicsStreamUseCase(
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
@ -80,16 +80,15 @@ class ForYouViewModelTest {
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase, getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase,
getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream, getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream,
getFollowableTopicsStream = getFollowableTopicsStreamUseCase, getFollowableTopicsStream = getFollowableTopicsStreamUseCase
savedStateHandle = SavedStateHandle()
) )
} }
@Test @Test
fun stateIsInitiallyLoading() = runTest { fun stateIsInitiallyLoading() = runTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, OnboardingUiState.Loading,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
} }
@ -97,14 +96,14 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest { fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, OnboardingUiState.Loading,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
@ -130,14 +129,14 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest { fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, OnboardingUiState.Loading,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
@ -146,16 +145,16 @@ class ForYouViewModelTest {
} }
@Test @Test
fun stateIsLoadingWhenTopicsAreLoading() = runTest { fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, OnboardingUiState.Loading,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value) assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
@ -164,16 +163,16 @@ class ForYouViewModelTest {
} }
@Test @Test
fun stateIsLoadingWhenAuthorsAreLoading() = runTest { fun onboardingStateIsLoadingWhenAuthorsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedAuthorIds(emptySet()) userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, OnboardingUiState.Loading,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value) assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
@ -182,9 +181,9 @@ class ForYouViewModelTest {
} }
@Test @Test
fun stateIsInterestsSelectionWhenNewsResourcesAreLoading() = runTest { fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -193,7 +192,7 @@ class ForYouViewModelTest {
userDataRepository.setFollowedAuthorIds(emptySet()) userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -265,7 +264,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -279,9 +278,9 @@ class ForYouViewModelTest {
} }
@Test @Test
fun stateIsInterestsSelectionAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest { fun onboardingIsShownAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -291,7 +290,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -363,7 +362,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -378,27 +377,28 @@ class ForYouViewModelTest {
} }
@Test @Test
fun stateIsWithoutInterestsSelectionAfterLoadingFollowedTopics() = runTest { fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet()) userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("0", "1")) userDataRepository.setFollowedTopicIds(setOf("0", "1"))
viewModel.dismissOnboarding()
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, OnboardingUiState.NotShown,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, OnboardingUiState.NotShown,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -417,52 +417,10 @@ class ForYouViewModelTest {
collectJob2.cancel() 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 @Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest { fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -472,7 +430,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -544,7 +502,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -556,7 +514,7 @@ class ForYouViewModelTest {
viewModel.updateTopicSelection("1", isChecked = true) viewModel.updateTopicSelection("1", isChecked = true)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -628,7 +586,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -651,9 +609,9 @@ class ForYouViewModelTest {
} }
@Test @Test
fun topicSelectionUpdatesAfterSelectingAuthor() = runTest { fun authorSelectionUpdatesAfterSelectingAuthor() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -663,7 +621,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -735,7 +693,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -747,7 +705,7 @@ class ForYouViewModelTest {
viewModel.updateAuthorSelection("1", isChecked = true) viewModel.updateAuthorSelection("1", isChecked = true)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -819,7 +777,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -844,7 +802,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest { fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -857,7 +815,7 @@ class ForYouViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -929,7 +887,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -943,9 +901,9 @@ class ForYouViewModelTest {
} }
@Test @Test
fun topicSelectionUpdatesAfterUnselectingAuthor() = runTest { fun authorSelectionUpdatesAfterUnselectingAuthor() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -958,7 +916,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection( OnboardingUiState.Shown(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -1030,331 +988,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.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<Int>(), 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<Int>(), 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
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -1370,19 +1004,20 @@ class ForYouViewModelTest {
@Test @Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest { fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("1")) userDataRepository.setFollowedTopicIds(setOf("1"))
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(setOf("1")) userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setHasDismissedOnboarding(true)
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved("2", true) viewModel.updateNewsResourceSaved("2", true)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, OnboardingUiState.NotShown,
viewModel.interestsSelectionUiState.value viewModel.onboardingUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(

@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository 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.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.TopicSortField
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor 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.domain.model.FollowableTopic
@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
class InterestsViewModel @Inject constructor( class InterestsViewModel @Inject constructor(
val userDataRepository: UserDataRepository, val userDataRepository: UserDataRepository,
getFollowableTopicsStream: GetFollowableTopicsStreamUseCase, getFollowableTopicsStream: GetFollowableTopicsStreamUseCase,
getPersistentSortedFollowableAuthorsStream: GetPersistentSortedFollowableAuthorsStreamUseCase getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase
) : ViewModel() { ) : ViewModel() {
private val _tabState = MutableStateFlow( private val _tabState = MutableStateFlow(
@ -51,7 +51,7 @@ class InterestsViewModel @Inject constructor(
val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow() val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> = combine( val uiState: StateFlow<InterestsUiState> = combine(
getPersistentSortedFollowableAuthorsStream(), getSortedFollowableAuthorsStream(),
getFollowableTopicsStream(sortBy = TopicSortField.NAME), getFollowableTopicsStream(sortBy = TopicSortField.NAME),
InterestsUiState::Interests InterestsUiState::Interests
).stateIn( ).stateIn(

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase 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.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
@ -53,8 +53,8 @@ class InterestsViewModelTest {
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userDataRepository = userDataRepository userDataRepository = userDataRepository
) )
private val getPersistentSortedFollowableAuthorsStream = private val getSortedFollowableAuthorsStream =
GetPersistentSortedFollowableAuthorsStreamUseCase( GetSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository, authorsRepository = authorsRepository,
userDataRepository = userDataRepository userDataRepository = userDataRepository
) )
@ -65,7 +65,7 @@ class InterestsViewModelTest {
viewModel = InterestsViewModel( viewModel = InterestsViewModel(
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getFollowableTopicsStream = getFollowableTopicsStreamUseCase, getFollowableTopicsStream = getFollowableTopicsStreamUseCase,
getPersistentSortedFollowableAuthorsStream = getPersistentSortedFollowableAuthorsStream getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream
) )
} }

Loading…
Cancel
Save