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) =
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
override suspend fun setHasDismissedOnboarding(hasDismissedOnboarding: Boolean) =
niaPreferencesDataSource.setHasDismissedOnboarding(hasDismissedOnboarding)
}

@ -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 setHasDismissedOnboarding(hasDismissedOnboarding: Boolean)
}

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

@ -58,7 +58,8 @@ class OfflineFirstUserDataRepositoryTest {
followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
hasDismissedOnboarding = false
),
subject.userDataStream.first()
)
@ -187,4 +188,15 @@ class OfflineFirstUserDataRepositoryTest {
.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"))
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.kotlinx.coroutines.android)

@ -52,7 +52,8 @@ class NiaPreferencesDataSource @Inject constructor(
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT ->
DarkThemeConfig.LIGHT
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK
}
},
hasDismissedOnboarding = it.hasDismissedOnboarding
)
}
@ -62,6 +63,7 @@ class NiaPreferencesDataSource @Inject constructor(
it.copy {
followedTopicIds.clear()
followedTopicIds.putAll(topicIds.associateWith { true })
updateHasDismissedOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -78,6 +80,7 @@ class NiaPreferencesDataSource @Inject constructor(
} else {
followedTopicIds.remove(topicId)
}
updateHasDismissedOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -91,6 +94,7 @@ class NiaPreferencesDataSource @Inject constructor(
it.copy {
followedAuthorIds.clear()
followedAuthorIds.putAll(authorIds.associateWith { true })
updateHasDismissedOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -107,6 +111,7 @@ class NiaPreferencesDataSource @Inject constructor(
} else {
followedAuthorIds.remove(authorId)
}
updateHasDismissedOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -188,4 +193,19 @@ class NiaPreferencesDataSource @Inject constructor(
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;
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.
*
* @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<Set<String>> =
userDataRepository.userDataStream.map { userdata ->
userdata.followedTopics
},
sortBy: TopicSortField = NONE
): Flow<List<FollowableTopic>> {
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> {
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) {

@ -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
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<String>): Flow<List<FollowableAuthor>> {
return authorsRepository.getAuthorsStream().map { authors ->
authors
.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in followedAuthorIds
)
}
operator fun invoke(): Flow<List<FollowableAuthor>> =
combine(
authorsRepository.getAuthorsStream(),
userDataRepository.userDataStream
) { authors, userData ->
authors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in userData.followedAuthors
)
}
.sortedBy { it.author.name }
}
}
}

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

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

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

@ -30,7 +30,8 @@ private val emptyUserData = UserData(
followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
hasDismissedOnboarding = false
)
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.
*

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

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

@ -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<FollowedInterestsUiState> =
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<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())
}
private val shouldShowOnboarding: Flow<Boolean> =
userDataRepository.userDataStream.map { !it.hasDismissedOnboarding }
val isSyncing = syncStatusMonitor.isSyncing
.stateIn(
@ -100,35 +58,23 @@ class ForYouViewModel @Inject constructor(
)
val feedState: StateFlow<NewsFeedUiState> =
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>(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.hasDismissedOnboarding &&
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>(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<ForYouInterestsSelectionUiState> =
val onboardingUiState: StateFlow<OnboardingUiState> =
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.setHasDismissedOnboarding(true)
}
}
}

@ -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<FollowableTopic>,
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 }
}
}

@ -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<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
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.setHasDismissedOnboarding(true)
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved("2", true)
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
OnboardingUiState.NotShown,
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(

@ -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<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> = combine(
getPersistentSortedFollowableAuthorsStream(),
getSortedFollowableAuthorsStream(),
getFollowableTopicsStream(sortBy = TopicSortField.NAME),
InterestsUiState::Interests
).stateIn(

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

Loading…
Cancel
Save