Add loading indicator while for you data is loading

Updates the for you view model to support displaying a loading
state when data hasn't loaded yet.

This is a somewhat involved CL, because there are multiple ways
in which data could be loading: We could have topics, but
the feed itself could be loading after we selected a new set
of topics.

Change-Id: I8662140c7132b195f85e69fee8e18745829ae975
pull/2/head
Alex Vanyo 3 years ago committed by Don Turner
parent 2e4c763db5
commit a5f679063d

@ -22,7 +22,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -51,7 +51,8 @@ class ForYouScreenTest {
fun circularProgressIndicator_whenScreenIsLoading_exists() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
interestsSelectionState = ForYouInterestsSelectionState.Loading,
feedState = ForYouFeedState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -70,7 +71,7 @@ class ForYouScreenTest {
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -127,7 +128,9 @@ class ForYouScreenTest {
),
isFollowed = false
),
),
)
),
feedState = ForYouFeedState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
@ -169,7 +172,7 @@ class ForYouScreenTest {
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -227,6 +230,8 @@ class ForYouScreenTest {
isFollowed = false
),
),
),
feedState = ForYouFeedState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
@ -274,7 +279,7 @@ class ForYouScreenTest {
fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -296,7 +301,7 @@ class ForYouScreenTest {
url = "",
imageUrl = ""
),
isFollowed = false
isFollowed = true
),
FollowableTopic(
topic = Topic(
@ -319,7 +324,7 @@ class ForYouScreenTest {
twitter = "",
mediumPage = ""
),
isFollowed = true
isFollowed = false
),
FollowableAuthor(
author = Author(
@ -332,6 +337,8 @@ class ForYouScreenTest {
isFollowed = false
),
),
),
feedState = ForYouFeedState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
@ -359,12 +366,6 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithText("Android Dev")
.assertIsDisplayed()
.assertIsOn()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Android Dev 2")
.assertIsDisplayed()
.assertIsOff()
.assertHasClickAction()
@ -380,4 +381,92 @@ class ForYouScreenTest {
.assertIsEnabled()
.assertHasClickAction()
}
@Test
fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() {
composeTestRule.setContent {
ForYouScreen(
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = true
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
),
),
feedState = ForYouFeedState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
// Scroll until the loading indicator is visible
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(
hasContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
)
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
.assertExists()
}
}

@ -0,0 +1,41 @@
/*
* 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 FollowedInterestsState {
/**
* The current state is unknown (hasn't loaded yet)
*/
object Unknown : FollowedInterestsState
/**
* The user hasn't followed any interests yet.
*/
object None : FollowedInterestsState
/**
* The user has followed the given (non-empty) set of [topicIds] or [authorIds].
*/
data class FollowedInterests(
val topicIds: Set<String>,
val authorIds: Set<String>
) : FollowedInterestsState
}

@ -0,0 +1,39 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
/**
* A sealed hierarchy describing the state of the feed on the for you screen.
*/
sealed interface ForYouFeedState {
/**
* The feed is still loading.
*/
object Loading : ForYouFeedState
/**
* The feed is loaded with the given list of news resources.
*/
data class Success(
/**
* The list of news resources contained in this [PopulatedFeed].
*/
val feed: List<SaveableNewsResource>
) : ForYouFeedState
}

@ -0,0 +1,49 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
/**
* A sealed hierarchy describing the interests selection state for the for you screen.
*/
sealed interface ForYouInterestsSelectionState {
/**
* The interests selection state is loading.
*/
object Loading : ForYouInterestsSelectionState
/**
* There is no interests selection state.
*/
object NoInterestsSelection : ForYouInterestsSelectionState
/**
* There is a interests selection state, with the given lists of topics and authors.
*/
data class WithInterestsSelection(
val topics: List<FollowableTopic>,
val authors: List<FollowableAuthor>
) : ForYouInterestsSelectionState {
/**
* True if the current in-progress selection can be saved.
*/
val canSaveInterests: Boolean get() =
topics.any { it.isFollowed } || authors.any { it.isFollowed }
}
}

@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells.Fixed
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
@ -76,9 +77,6 @@ import com.google.samples.apps.nowinandroid.core.ui.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTypography
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection
import kotlinx.datetime.Instant
@Composable
@ -86,10 +84,12 @@ fun ForYouRoute(
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val interestsSelectionState by viewModel.interestsSelectionState.collectAsState()
val feedState by viewModel.feedState.collectAsState()
ForYouScreen(
modifier = modifier,
uiState = uiState,
interestsSelectionState = interestsSelectionState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests,
@ -99,7 +99,8 @@ fun ForYouRoute(
@Composable
fun ForYouScreen(
uiState: ForYouFeedUiState,
interestsSelectionState: ForYouInterestsSelectionState,
feedState: ForYouFeedState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
@ -132,74 +133,91 @@ fun ForYouScreen(
)
)
}
when (uiState) {
is ForYouFeedUiState.Loading -> {
when (interestsSelectionState) {
ForYouInterestsSelectionState.Loading -> {
item {
LoadingWheel(
modifier = modifier,
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
is PopulatedFeed -> {
when (uiState) {
is FeedWithInterestsSelection -> {
item {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = NiaTypography.titleMedium
)
}
item {
Text(
text = stringResource(R.string.onboarding_guidance_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp),
textAlign = TextAlign.Center,
style = NiaTypography.bodyMedium
)
}
item {
AuthorsCarousel(
authors = uiState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier.padding(vertical = 8.dp)
)
}
item {
TopicSelection(
uiState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
}
item {
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = saveFollowedTopics,
enabled = uiState.canSaveInterests,
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
) {
Text(text = stringResource(R.string.done))
}
}
ForYouInterestsSelectionState.NoInterestsSelection -> Unit
is ForYouInterestsSelectionState.WithInterestsSelection -> {
item {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = NiaTypography.titleMedium
)
}
item {
Text(
text = stringResource(R.string.onboarding_guidance_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp),
textAlign = TextAlign.Center,
style = NiaTypography.bodyMedium
)
}
item {
AuthorsCarousel(
authors = interestsSelectionState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier.padding(vertical = 8.dp)
)
}
item {
TopicSelection(
interestsSelectionState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
}
item {
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = saveFollowedTopics,
enabled = interestsSelectionState.canSaveInterests,
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
) {
Text(text = stringResource(R.string.done))
}
}
is FeedWithoutTopicSelection -> Unit
}
}
}
items(uiState.feed) { (newsResource: NewsResource, isBookmarked: Boolean) ->
when (feedState) {
ForYouFeedState.Loading -> {
// Avoid showing a second loading wheel if we already are for the interests
// selection
if (interestsSelectionState !is ForYouInterestsSelectionState.Loading) {
item {
LoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
}
is ForYouFeedState.Success -> {
items(feedState.feed) { (newsResource: NewsResource, isBookmarked: Boolean) ->
val launchResourceIntent =
Intent(Intent.ACTION_VIEW, Uri.parse(newsResource.url))
val context = LocalContext.current
@ -231,7 +249,7 @@ fun ForYouScreen(
@Composable
private fun TopicSelection(
uiState: FeedWithInterestsSelection,
interestsSelectionState: ForYouInterestsSelectionState.WithInterestsSelection,
onTopicCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
@ -253,7 +271,7 @@ private fun TopicSelection(
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth()
) {
items(uiState.topics) {
items(interestsSelectionState.topics) {
SingleTopicButton(
name = it.topic.name,
topicId = it.topic.id,
@ -314,7 +332,8 @@ fun ForYouScreenLoading() {
MaterialTheme {
Surface {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
interestsSelectionState = ForYouInterestsSelectionState.Loading,
feedState = ForYouFeedState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -328,7 +347,7 @@ fun ForYouScreenLoading() {
@Composable
fun ForYouScreenTopicSelection() {
ForYouScreen(
uiState = FeedWithInterestsSelection(
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -364,86 +383,6 @@ fun ForYouScreenTopicSelection() {
isFollowed = false
),
),
feed = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = "1",
episodeId = "52",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series " +
"and everything the Android Developers YouTube channel has to offer. " +
"During the Android Developer Summit, our YouTube channel reached 1 " +
"million subscribers! Heres a small video to thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
)
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "2",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "3",
episodeId = "52",
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
@ -475,7 +414,10 @@ fun ForYouScreenTopicSelection() {
),
isFollowed = false
)
),
)
),
feedState = ForYouFeedState.Success(
feed = saveableNewsResource,
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
@ -490,8 +432,9 @@ fun PopulatedFeed() {
MaterialTheme {
Surface {
ForYouScreen(
uiState = FeedWithoutTopicSelection(
feed = emptyList()
interestsSelectionState = ForYouInterestsSelectionState.NoInterestsSelection,
feedState = ForYouFeedState.Success(
feed = saveableNewsResource
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
@ -501,3 +444,84 @@ fun PopulatedFeed() {
}
}
}
private val saveableNewsResource = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = "1",
episodeId = "52",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series " +
"and everything the Android Developers YouTube channel has to offer. " +
"During the Android Developer Summit, our YouTube channel reached 1 " +
"million subscribers! Heres a small video to thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
)
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "2",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "3",
episodeId = "52",
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
)

@ -24,7 +24,6 @@ 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.domain.combine
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
@ -32,14 +31,20 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.FollowedInterests
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.None
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.Unknown
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
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
@ -52,24 +57,24 @@ class ForYouViewModel @Inject constructor(
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val followedInterestsStateFlow =
private val followedInterestsState: StateFlow<FollowedInterestsState> =
combine(
authorsRepository.getFollowedAuthorIdsStream(),
topicsRepository.getFollowedTopicIdsStream(),
) { followedAuthors, followedTopics ->
if (followedAuthors.isEmpty() && followedTopics.isEmpty()) {
FollowedInterestsState.None
None
} else {
FollowedInterestsState.FollowedInterests(
FollowedInterests(
authorIds = followedAuthors,
topicIds = followedTopics
)
}
}
.stateIn(
viewModelScope,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FollowedInterestsState.Unknown
initialValue = Unknown
)
/**
@ -98,77 +103,92 @@ class ForYouViewModel @Inject constructor(
mutableStateOf<Set<String>>(emptySet())
}
val uiState: StateFlow<ForYouFeedUiState> = combine(
followedInterestsStateFlow,
topicsRepository.getTopicsStream(),
snapshotFlow { inProgressTopicSelection },
authorsRepository.getAuthorsStream(),
snapshotFlow { inProgressAuthorSelection },
snapshotFlow { savedNewsResources }
) { followedInterestsUserState, availableTopics, inProgressTopicSelection,
availableAuthors, inProgressAuthorSelection, savedNewsResources ->
val feedState: StateFlow<ForYouFeedState> =
combine(
followedInterestsState,
snapshotFlow { inProgressTopicSelection },
snapshotFlow { inProgressAuthorSelection },
snapshotFlow { savedNewsResources }
) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection,
savedNewsResources ->
fun mapToSaveableFeed(feed: List<NewsResource>): List<SaveableNewsResource> =
feed.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResources.contains(newsResource.id)
)
when (followedInterestsUserState) {
// If we don't know the current selection state, emit loading.
Unknown -> flowOf<ForYouFeedState>(ForYouFeedState.Loading)
// If the user has followed topics, use those followed topics to populate the feed
is FollowedInterests -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = followedInterestsUserState.topicIds,
filterAuthorIds = followedInterestsUserState.authorIds
).mapToFeedState(savedNewsResources)
}
// 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<ForYouFeedState>(ForYouFeedState.Success(emptyList()))
} else {
newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection
).mapToFeedState(savedNewsResources)
}
}
}
}
// Flatten the feed flows.
// As the selected topics and topic state changes, this will cancel the old feed
// monitoring and start the new one.
.flatMapLatest { it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ForYouFeedState.Loading
)
when (followedInterestsUserState) {
// If we don't know the current selection state, just emit loading.
FollowedInterestsState.Unknown -> flowOf<ForYouFeedUiState>(ForYouFeedUiState.Loading)
// If the user has followed topics, use those followed topics to populate the feed
is FollowedInterestsState.FollowedInterests -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = followedInterestsUserState.topicIds,
filterAuthorIds = followedInterestsUserState.authorIds
)
.map(::mapToSaveableFeed)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = feed
val interestsSelectionState: StateFlow<ForYouInterestsSelectionState> =
combine(
followedInterestsState,
topicsRepository.getTopicsStream(),
authorsRepository.getAuthorsStream(),
snapshotFlow { inProgressTopicSelection },
snapshotFlow { inProgressAuthorSelection },
) { followedInterestsUserState, availableTopics, availableAuthors, inProgressTopicSelection,
inProgressAuthorSelection ->
when (followedInterestsUserState) {
Unknown -> ForYouInterestsSelectionState.Loading
is FollowedInterests -> ForYouInterestsSelectionState.NoInterestsSelection
None -> {
val topics = availableTopics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in inProgressTopicSelection
)
}
}
// If the user hasn't followed topics yet, show the topic selection, as well as a
// realtime populated feed based on those in-progress topic selections.
FollowedInterestsState.None -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection
)
.map(::mapToSaveableFeed)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection(
topics = availableTopics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in inProgressTopicSelection
)
},
authors = availableAuthors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in inProgressAuthorSelection
)
},
feed = feed
val authors = availableAuthors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in inProgressAuthorSelection
)
}
if (topics.isEmpty() && authors.isEmpty()) {
ForYouInterestsSelectionState.Loading
} else {
ForYouInterestsSelectionState.WithInterestsSelection(
topics = topics,
authors = authors
)
}
}
}
}
}
// Flatten the feed flows.
// As the selected topics and topic state changes, this will cancel the old feed monitoring
// and start the new one.
.flatMapLatest { it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ForYouFeedUiState.Loading
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ForYouInterestsSelectionState.Loading
)
fun updateTopicSelection(topicId: String, isChecked: Boolean) {
withMutableSnapshot {
@ -223,67 +243,17 @@ class ForYouViewModel @Inject constructor(
}
}
/**
* A sealed hierarchy for the user's current followed interests state.
*/
private sealed interface FollowedInterestsState {
/**
* The current state is unknown (hasn't loaded yet)
*/
object Unknown : FollowedInterestsState
/**
* The user hasn't followed any interests yet.
*/
object None : FollowedInterestsState
/**
* The user has followed the given (non-empty) set of [topicIds] or [authorIds].
*/
data class FollowedInterests(
val topicIds: Set<String>,
val authorIds: Set<String>
) : FollowedInterestsState
}
/**
* A sealed hierarchy describing the for you screen state.
*/
sealed interface ForYouFeedUiState {
/**
* The screen is still loading.
*/
object Loading : ForYouFeedUiState
/**
* Loaded with a populated [feed] of [NewsResource]s.
*/
sealed interface PopulatedFeed : ForYouFeedUiState {
/**
* The list of news resources contained in this [PopulatedFeed].
*/
val feed: List<SaveableNewsResource>
/**
* The feed, along with a list of interests that can be selected.
*/
data class FeedWithInterestsSelection(
val topics: List<FollowableTopic>,
val authors: List<FollowableAuthor>,
override val feed: List<SaveableNewsResource>
) : PopulatedFeed {
val canSaveInterests: Boolean =
topics.any { it.isFollowed } || authors.any { it.isFollowed }
private fun Flow<List<NewsResource>>.mapToFeedState(
savedNewsResources: Set<String>
): Flow<ForYouFeedState> =
filterNot { it.isEmpty() }
.map { newsResources ->
newsResources.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResources.contains(newsResource.id)
)
}
}
/**
* Just the feed.
*/
data class FeedWithoutTopicSelection(
override val feed: List<SaveableNewsResource>
) : PopulatedFeed
}
}
.map<List<SaveableNewsResource>, ForYouFeedState>(ForYouFeedState::Success)
.onStart { emit(ForYouFeedState.Loading) }

Loading…
Cancel
Save