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 2 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.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff 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.hasScrollToNodeAction
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -51,7 +51,8 @@ class ForYouScreenTest {
fun circularProgressIndicator_whenScreenIsLoading_exists() { fun circularProgressIndicator_whenScreenIsLoading_exists() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.Loading, interestsSelectionState = ForYouInterestsSelectionState.Loading,
feedState = ForYouFeedState.Loading,
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
@ -70,7 +71,7 @@ class ForYouScreenTest {
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() { fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -127,7 +128,9 @@ class ForYouScreenTest {
), ),
isFollowed = false isFollowed = false
), ),
)
), ),
feedState = ForYouFeedState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -169,7 +172,7 @@ class ForYouScreenTest {
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -227,6 +230,8 @@ class ForYouScreenTest {
isFollowed = false isFollowed = false
), ),
), ),
),
feedState = ForYouFeedState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -274,7 +279,7 @@ class ForYouScreenTest {
fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -296,7 +301,7 @@ class ForYouScreenTest {
url = "", url = "",
imageUrl = "" imageUrl = ""
), ),
isFollowed = false isFollowed = true
), ),
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -319,7 +324,7 @@ class ForYouScreenTest {
twitter = "", twitter = "",
mediumPage = "" mediumPage = ""
), ),
isFollowed = true isFollowed = false
), ),
FollowableAuthor( FollowableAuthor(
author = Author( author = Author(
@ -332,6 +337,8 @@ class ForYouScreenTest {
isFollowed = false isFollowed = false
), ),
), ),
),
feedState = ForYouFeedState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -359,12 +366,6 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNodeWithText("Android Dev") .onNodeWithText("Android Dev")
.assertIsDisplayed() .assertIsDisplayed()
.assertIsOn()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Android Dev 2")
.assertIsDisplayed()
.assertIsOff() .assertIsOff()
.assertHasClickAction() .assertHasClickAction()
@ -380,4 +381,92 @@ class ForYouScreenTest {
.assertIsEnabled() .assertIsEnabled()
.assertHasClickAction() .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.safeDrawing
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells.Fixed import androidx.compose.foundation.lazy.grid.GridCells.Fixed
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid 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.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons 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.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 import kotlinx.datetime.Instant
@Composable @Composable
@ -86,10 +84,12 @@ fun ForYouRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel() viewModel: ForYouViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val interestsSelectionState by viewModel.interestsSelectionState.collectAsState()
val feedState by viewModel.feedState.collectAsState()
ForYouScreen( ForYouScreen(
modifier = modifier, modifier = modifier,
uiState = uiState, interestsSelectionState = interestsSelectionState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection, onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests, saveFollowedTopics = viewModel::saveFollowedInterests,
@ -99,7 +99,8 @@ fun ForYouRoute(
@Composable @Composable
fun ForYouScreen( fun ForYouScreen(
uiState: ForYouFeedUiState, interestsSelectionState: ForYouInterestsSelectionState,
feedState: ForYouFeedState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
onAuthorCheckedChanged: (String, Boolean) -> Unit, onAuthorCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
@ -132,18 +133,20 @@ fun ForYouScreen(
) )
) )
} }
when (uiState) {
is ForYouFeedUiState.Loading -> { when (interestsSelectionState) {
ForYouInterestsSelectionState.Loading -> {
item { item {
LoadingWheel( LoadingWheel(
modifier = modifier, modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(id = R.string.for_you_loading), contentDesc = stringResource(id = R.string.for_you_loading),
) )
} }
} }
is PopulatedFeed -> { ForYouInterestsSelectionState.NoInterestsSelection -> Unit
when (uiState) { is ForYouInterestsSelectionState.WithInterestsSelection -> {
is FeedWithInterestsSelection -> {
item { item {
Text( Text(
text = stringResource(R.string.onboarding_guidance_title), text = stringResource(R.string.onboarding_guidance_title),
@ -166,14 +169,14 @@ fun ForYouScreen(
} }
item { item {
AuthorsCarousel( AuthorsCarousel(
authors = uiState.authors, authors = interestsSelectionState.authors,
onAuthorClick = onAuthorCheckedChanged, onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
) )
} }
item { item {
TopicSelection( TopicSelection(
uiState, interestsSelectionState,
onTopicCheckedChanged, onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp) Modifier.padding(bottom = 8.dp)
) )
@ -186,7 +189,7 @@ fun ForYouScreen(
) { ) {
Button( Button(
onClick = saveFollowedTopics, onClick = saveFollowedTopics,
enabled = uiState.canSaveInterests, enabled = interestsSelectionState.canSaveInterests,
modifier = Modifier modifier = Modifier
.padding(horizontal = 40.dp) .padding(horizontal = 40.dp)
.width(364.dp) .width(364.dp)
@ -196,10 +199,25 @@ fun ForYouScreen(
} }
} }
} }
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 = val launchResourceIntent =
Intent(Intent.ACTION_VIEW, Uri.parse(newsResource.url)) Intent(Intent.ACTION_VIEW, Uri.parse(newsResource.url))
val context = LocalContext.current val context = LocalContext.current
@ -231,7 +249,7 @@ fun ForYouScreen(
@Composable @Composable
private fun TopicSelection( private fun TopicSelection(
uiState: FeedWithInterestsSelection, interestsSelectionState: ForYouInterestsSelectionState.WithInterestsSelection,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -253,7 +271,7 @@ private fun TopicSelection(
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() })) .heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth() .fillMaxWidth()
) { ) {
items(uiState.topics) { items(interestsSelectionState.topics) {
SingleTopicButton( SingleTopicButton(
name = it.topic.name, name = it.topic.name,
topicId = it.topic.id, topicId = it.topic.id,
@ -314,7 +332,8 @@ fun ForYouScreenLoading() {
MaterialTheme { MaterialTheme {
Surface { Surface {
ForYouScreen( ForYouScreen(
uiState = ForYouFeedUiState.Loading, interestsSelectionState = ForYouInterestsSelectionState.Loading,
feedState = ForYouFeedState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
@ -328,7 +347,7 @@ fun ForYouScreenLoading() {
@Composable @Composable
fun ForYouScreenTopicSelection() { fun ForYouScreenTopicSelection() {
ForYouScreen( ForYouScreen(
uiState = FeedWithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
topics = listOf( topics = listOf(
FollowableTopic( FollowableTopic(
topic = Topic( topic = Topic(
@ -364,7 +383,69 @@ fun ForYouScreenTopicSelection() {
isFollowed = false isFollowed = false
), ),
), ),
feed = listOf( authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
)
),
feedState = ForYouFeedState.Success(
feed = saveableNewsResource,
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
@Preview
@Composable
fun PopulatedFeed() {
MaterialTheme {
Surface {
ForYouScreen(
interestsSelectionState = ForYouInterestsSelectionState.NoInterestsSelection,
feedState = ForYouFeedState.Success(
feed = saveableNewsResource
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
private val saveableNewsResource = listOf(
SaveableNewsResource( SaveableNewsResource(
newsResource = NewsResource( newsResource = NewsResource(
id = "1", id = "1",
@ -443,61 +524,4 @@ fun ForYouScreenTopicSelection() {
), ),
isSaved = false isSaved = false
), ),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
),
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
) )
}
@Preview
@Composable
fun PopulatedFeed() {
MaterialTheme {
Surface {
ForYouScreen(
uiState = FeedWithoutTopicSelection(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}

@ -24,7 +24,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable 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.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -52,24 +57,24 @@ class ForYouViewModel @Inject constructor(
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
private val followedInterestsStateFlow = private val followedInterestsState: StateFlow<FollowedInterestsState> =
combine( combine(
authorsRepository.getFollowedAuthorIdsStream(), authorsRepository.getFollowedAuthorIdsStream(),
topicsRepository.getFollowedTopicIdsStream(), topicsRepository.getFollowedTopicIdsStream(),
) { followedAuthors, followedTopics -> ) { followedAuthors, followedTopics ->
if (followedAuthors.isEmpty() && followedTopics.isEmpty()) { if (followedAuthors.isEmpty() && followedTopics.isEmpty()) {
FollowedInterestsState.None None
} else { } else {
FollowedInterestsState.FollowedInterests( FollowedInterests(
authorIds = followedAuthors, authorIds = followedAuthors,
topicIds = followedTopics topicIds = followedTopics
) )
} }
} }
.stateIn( .stateIn(
viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = FollowedInterestsState.Unknown initialValue = Unknown
) )
/** /**
@ -98,76 +103,91 @@ class ForYouViewModel @Inject constructor(
mutableStateOf<Set<String>>(emptySet()) mutableStateOf<Set<String>>(emptySet())
} }
val uiState: StateFlow<ForYouFeedUiState> = combine( val feedState: StateFlow<ForYouFeedState> =
followedInterestsStateFlow, combine(
topicsRepository.getTopicsStream(), followedInterestsState,
snapshotFlow { inProgressTopicSelection }, snapshotFlow { inProgressTopicSelection },
authorsRepository.getAuthorsStream(),
snapshotFlow { inProgressAuthorSelection }, snapshotFlow { inProgressAuthorSelection },
snapshotFlow { savedNewsResources } snapshotFlow { savedNewsResources }
) { followedInterestsUserState, availableTopics, inProgressTopicSelection, ) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection,
availableAuthors, inProgressAuthorSelection, savedNewsResources -> savedNewsResources ->
fun mapToSaveableFeed(feed: List<NewsResource>): List<SaveableNewsResource> =
feed.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResources.contains(newsResource.id)
)
}
when (followedInterestsUserState) { when (followedInterestsUserState) {
// If we don't know the current selection state, just emit loading. // If we don't know the current selection state, emit loading.
FollowedInterestsState.Unknown -> flowOf<ForYouFeedUiState>(ForYouFeedUiState.Loading) Unknown -> flowOf<ForYouFeedState>(ForYouFeedState.Loading)
// If the user has followed topics, use those followed topics to populate the feed // If the user has followed topics, use those followed topics to populate the feed
is FollowedInterestsState.FollowedInterests -> { is FollowedInterests -> {
newsRepository.getNewsResourcesStream( newsRepository.getNewsResourcesStream(
filterTopicIds = followedInterestsUserState.topicIds, filterTopicIds = followedInterestsUserState.topicIds,
filterAuthorIds = followedInterestsUserState.authorIds filterAuthorIds = followedInterestsUserState.authorIds
) ).mapToFeedState(savedNewsResources)
.map(::mapToSaveableFeed)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = feed
)
}
} }
// If the user hasn't followed topics yet, show the topic selection, as well as a // If the user hasn't followed interests yet, show a realtime populated feed based
// realtime populated feed based on those in-progress topic selections. // on the in-progress interests selections, if there are any.
FollowedInterestsState.None -> { None -> {
if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
flowOf<ForYouFeedState>(ForYouFeedState.Success(emptyList()))
} else {
newsRepository.getNewsResourcesStream( newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection, filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection 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
) )
.map(::mapToSaveableFeed)
.map { feed -> val interestsSelectionState: StateFlow<ForYouInterestsSelectionState> =
ForYouFeedUiState.PopulatedFeed.FeedWithInterestsSelection( combine(
topics = availableTopics.map { topic -> 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( FollowableTopic(
topic = topic, topic = topic,
isFollowed = topic.id in inProgressTopicSelection isFollowed = topic.id in inProgressTopicSelection
) )
}, }
authors = availableAuthors.map { author -> val authors = availableAuthors.map { author ->
FollowableAuthor( FollowableAuthor(
author = author, author = author,
isFollowed = author.id in inProgressAuthorSelection isFollowed = author.id in inProgressAuthorSelection
) )
}, }
feed = feed
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( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.WhileSubscribed(5_000),
initialValue = ForYouFeedUiState.Loading initialValue = ForYouInterestsSelectionState.Loading
) )
fun updateTopicSelection(topicId: String, isChecked: Boolean) { fun updateTopicSelection(topicId: String, isChecked: Boolean) {
@ -223,67 +243,17 @@ class ForYouViewModel @Inject constructor(
} }
} }
/** private fun Flow<List<NewsResource>>.mapToFeedState(
* A sealed hierarchy for the user's current followed interests state. savedNewsResources: Set<String>
*/ ): Flow<ForYouFeedState> =
private sealed interface FollowedInterestsState { filterNot { it.isEmpty() }
.map { newsResources ->
/** newsResources.map { newsResource ->
* The current state is unknown (hasn't loaded yet) SaveableNewsResource(
*/ newsResource = newsResource,
object Unknown : FollowedInterestsState isSaved = savedNewsResources.contains(newsResource.id)
)
/**
* 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 }
}
/**
* 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