Used the ViewHolder pattern for lists displaying news resources

Change-Id: I45a068ce61bdedc2003a480797edac19347e4a9e
tj/viewholder-pattern
Adetunji Dahunsi 1 year ago
parent 4633609930
commit 4b3d542ae5

@ -21,18 +21,23 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
* Data layer implementation for [NewsResource] * Query parameters for fetching news resources
*/ */
interface NewsRepository : Syncable { data class NewsResourceQuery(
/** /**
* Returns available news resources as a stream. * Topics to filter the fetched news resources by, or null for no filtering.
*/ */
fun getNewsResources(): Flow<List<NewsResource>> val filterTopicIds: Set<String>? = null
)
/**
* Data layer implementation for [NewsResource]
*/
interface NewsRepository : Syncable {
/** /**
* Returns available news resources as a stream filtered by topics. * Returns available news resources as a stream.
*/ */
fun getNewsResources( fun getNewsResources(
filterTopicIds: Set<String> = emptySet(), query: NewsResourceQuery,
): Flow<List<NewsResource>> ): Flow<List<NewsResource>>
} }

@ -43,18 +43,13 @@ class OfflineFirstNewsRepository @Inject constructor(
private val topicDao: TopicDao, private val topicDao: TopicDao,
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
override fun getNewsResources(): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
newsResourceDao.getNewsResources() filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterTopicIds = query.filterTopicIds != null
)
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources(
filterTopicIds: Set<String>
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
filterTopicIds = filterTopicIds
)
.map { it.map(PopulatedNewsResource::asExternalModel) }
override suspend fun syncWith(synchronizer: Synchronizer) = override suspend fun syncWith(synchronizer: Synchronizer) =
synchronizer.changeListSync( synchronizer.changeListSync(
versionReader = ChangeListVersions::newsResourceVersion, versionReader = ChangeListVersions::newsResourceVersion,

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -42,27 +43,21 @@ class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource private val datasource: FakeNiaNetworkDataSource
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
override fun getNewsResources(): Flow<List<NewsResource>> =
flow {
emit(
datasource.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
)
}.flowOn(ioDispatcher)
override fun getNewsResources(
filterTopicIds: Set<String>,
): Flow<List<NewsResource>> =
flow { flow {
emit( emit(
datasource datasource
.getNewsResources() .getNewsResources()
.filter { it.topics.intersect(filterTopicIds).isNotEmpty() } .let { newsResources ->
when (val filterTopicIds = query.filterTopicIds) {
null -> newsResources
else -> newsResources.filter {
it.topics.intersect(filterTopicIds).isNotEmpty()
}
}
}
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)

@ -80,10 +80,15 @@ class OfflineFirstNewsRepositoryTest {
fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() = fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() =
runTest { runTest {
assertEquals( assertEquals(
newsResourceDao.getNewsResources() newsResourceDao.getNewsResources(
filterTopicIds = emptySet(),
useFilterTopicIds = false,
)
.first() .first()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources() subject.getNewsResources(
NewsResourceQuery()
)
.first() .first()
) )
} }
@ -94,11 +99,14 @@ class OfflineFirstNewsRepositoryTest {
assertEquals( assertEquals(
newsResourceDao.getNewsResources( newsResourceDao.getNewsResources(
filterTopicIds = filteredInterestsIds, filterTopicIds = filteredInterestsIds,
useFilterTopicIds = true,
) )
.first() .first()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources( subject.getNewsResources(
filterTopicIds = filteredInterestsIds, NewsResourceQuery(
filterTopicIds = filteredInterestsIds,
)
) )
.first() .first()
) )
@ -106,7 +114,9 @@ class OfflineFirstNewsRepositoryTest {
assertEquals( assertEquals(
emptyList(), emptyList(),
subject.getNewsResources( subject.getNewsResources(
filterTopicIds = nonPresentInterestsIds, NewsResourceQuery(
filterTopicIds = nonPresentInterestsIds,
)
) )
.first() .first()
) )
@ -121,7 +131,10 @@ class OfflineFirstNewsRepositoryTest {
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
val newsResourcesFromDb = newsResourceDao.getNewsResources() val newsResourcesFromDb = newsResourceDao.getNewsResources(
filterTopicIds = emptySet(),
useFilterTopicIds = false,
)
.first() .first()
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)
@ -161,7 +174,10 @@ class OfflineFirstNewsRepositoryTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val newsResourcesFromDb = newsResourceDao.getNewsResources() val newsResourcesFromDb = newsResourceDao.getNewsResources(
filterTopicIds = emptySet(),
useFilterTopicIds = false,
)
.first() .first()
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)
@ -201,7 +217,10 @@ class OfflineFirstNewsRepositoryTest {
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
.filter { it.id in changeListIds } .filter { it.id in changeListIds }
val newsResourcesFromDb = newsResourceDao.getNewsResources() val newsResourcesFromDb = newsResourceDao.getNewsResources(
filterTopicIds = emptySet(),
useFilterTopicIds = false,
)
.first() .first()
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)

@ -52,18 +52,20 @@ class TestNewsResourceDao : NewsResourceDao {
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf() internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
override fun getNewsResources(): Flow<List<PopulatedNewsResource>> =
entitiesStateFlow.map {
it.map(NewsResourceEntity::asPopulatedNewsResource)
}
override fun getNewsResources( override fun getNewsResources(
filterTopicIds: Set<String> filterTopicIds: Set<String>,
useFilterTopicIds: Boolean
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> =
getNewsResources() entitiesStateFlow
.map { resources -> .map { it.map(NewsResourceEntity::asPopulatedNewsResource) }
resources.filter { resource -> .map { populatedNewsResources ->
resource.topics.any { it.id in filterTopicIds } when {
useFilterTopicIds ->
populatedNewsResources
.filter { populatedNewsResource ->
populatedNewsResource.topics.any { it.id in filterTopicIds }
}
else -> populatedNewsResources
} }
} }

@ -73,7 +73,10 @@ class NewsResourceDaoTest {
newsResourceEntities newsResourceEntities
) )
val savedNewsResourceEntities = newsResourceDao.getNewsResources() val savedNewsResourceEntities = newsResourceDao.getNewsResources(
filterTopicIds = emptySet(),
useFilterTopicIds = false
)
.first() .first()
assertEquals( assertEquals(
@ -135,6 +138,7 @@ class NewsResourceDaoTest {
filterTopicIds = topicEntities filterTopicIds = topicEntities
.map(TopicEntity::id) .map(TopicEntity::id)
.toSet(), .toSet(),
useFilterTopicIds = true,
).first() ).first()
assertEquals( assertEquals(
@ -175,7 +179,10 @@ class NewsResourceDaoTest {
assertEquals( assertEquals(
toKeep.map(NewsResourceEntity::id) toKeep.map(NewsResourceEntity::id)
.toSet(), .toSet(),
newsResourceDao.getNewsResources().first() newsResourceDao.getNewsResources(
filterTopicIds = emptySet(),
useFilterTopicIds = false,
).first()
.map { it.entity.id } .map { it.entity.id }
.toSet() .toSet()
) )

@ -34,29 +34,26 @@ import kotlinx.coroutines.flow.Flow
*/ */
@Dao @Dao
interface NewsResourceDao { interface NewsResourceDao {
@Transaction
@Query(
value = """
SELECT * FROM news_resources
ORDER BY publish_date DESC
"""
)
fun getNewsResources(): Flow<List<PopulatedNewsResource>>
@Transaction @Transaction
@Query( @Query(
value = """ value = """
SELECT * FROM news_resources SELECT * FROM news_resources
WHERE id in WHERE
( CASE WHEN :useFilterTopicIds
SELECT news_resource_id FROM news_resources_topics THEN id IN
WHERE topic_id IN (:filterTopicIds) (
) SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds)
)
ELSE 1
END
ORDER BY publish_date DESC ORDER BY publish_date DESC
""" """
) )
fun getNewsResources( fun getNewsResources(
filterTopicIds: Set<String> = emptySet(), filterTopicIds: Set<String>,
useFilterTopicIds: Boolean
): Flow<List<PopulatedNewsResource>> ): Flow<List<PopulatedNewsResource>>
/** /**

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.domain package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
@ -38,17 +39,13 @@ class GetUserNewsResourcesUseCase @Inject constructor(
/** /**
* Returns a list of UserNewsResources which match the supplied set of topic ids. * Returns a list of UserNewsResources which match the supplied set of topic ids.
* *
* @param filterTopicIds - A set of topic ids used to filter the list of news resources. If * @param newsResourceQuery - Query parameters for the request.
* this is empty the list of news resources will not be filtered.
*/ */
operator fun invoke( operator fun invoke(
filterTopicIds: Set<String> = emptySet() newsResourceQuery: NewsResourceQuery
): Flow<List<UserNewsResource>> = ): Flow<List<UserNewsResource>> =
if (filterTopicIds.isEmpty()) { newsRepository.getNewsResources(query = newsResourceQuery)
newsRepository.getNewsResources() .mapToUserNewsResources(userDataRepository.userData)
} else {
newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
}.mapToUserNewsResources(userDataRepository.userData)
} }
private fun Flow<List<NewsResource>>.mapToUserNewsResources( private fun Flow<List<NewsResource>>.mapToUserNewsResources(

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.domain package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
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.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
@ -45,7 +46,7 @@ class GetUserNewsResourcesUseCaseTest {
fun whenNoFilters_allNewsResourcesAreReturned() = runTest { fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the user news resources stream. // Obtain the user news resources stream.
val userNewsResources = useCase() val userNewsResources = useCase(NewsResourceQuery())
// Send some news resources and user data into the data repositories. // Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
@ -69,7 +70,11 @@ class GetUserNewsResourcesUseCaseTest {
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id. // Obtain a stream of user news resources for the given topic id.
val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id)) val userNewsResources = useCase(
NewsResourceQuery(
filterTopicIds = setOf(sampleTopic1.id),
)
)
// Send test data into the repositories. // Send test data into the repositories.
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.core.testing.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
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.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@ -33,14 +34,16 @@ class TestNewsRepository : NewsRepository {
private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> = private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override fun getNewsResources(): Flow<List<NewsResource>> = newsResourcesFlow override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
newsResourcesFlow
override fun getNewsResources(filterTopicIds: Set<String>): Flow<List<NewsResource>> = .map { newsResources ->
getNewsResources().map { newsResources -> when (val filterTopicIds = query.filterTopicIds) {
newsResources.filter { null -> newsResources
it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty() else -> newsResources.filter {
it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
}
}
} }
}
/** /**
* A test-only API to allow controlling the list of news resources from tests. * A test-only API to allow controlling the list of news resources from tests.

@ -21,9 +21,7 @@ import android.net.Uri
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -31,6 +29,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices
@ -41,37 +40,32 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
/** /**
* An extension on [LazyListScope] defining a feed with news resources. * Renders a [UserNewsResource] as a clickable card.
* Depending on the [feedState], this might emit no items.
*/ */
fun LazyGridScope.newsFeed( @Composable
feedState: NewsFeedUiState, fun NewsItem(
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit userNewsResource: UserNewsResource,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) { ) {
when (feedState) { val resourceUrl by remember {
NewsFeedUiState.Loading -> Unit mutableStateOf(Uri.parse(userNewsResource.url))
is NewsFeedUiState.Success -> { }
items(feedState.feed, key = { it.id }) { userNewsResource -> val context = LocalContext.current
val resourceUrl by remember { val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
mutableStateOf(Uri.parse(userNewsResource.url))
}
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResource, modifier = modifier,
isBookmarked = userNewsResource.isSaved, userNewsResource = userNewsResource,
onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) }, isBookmarked = userNewsResource.isSaved,
onToggleBookmark = { onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) },
onNewsResourcesCheckedChanged( onToggleBookmark = {
userNewsResource.id, onNewsResourcesCheckedChanged(
!userNewsResource.isSaved userNewsResource.id,
) !userNewsResource.isSaved
} )
)
}
} }
} )
} }
fun launchCustomChromeTab(context: Context, uri: Uri, @ColorInt toolbarColor: Int) { fun launchCustomChromeTab(context: Context, uri: Uri, @ColorInt toolbarColor: Int) {
@ -84,50 +78,20 @@ fun launchCustomChromeTab(context: Context, uri: Uri, @ColorInt toolbarColor: In
customTabsIntent.launchUrl(context, uri) customTabsIntent.launchUrl(context, uri)
} }
/**
* A sealed hierarchy describing the state of the feed of news resources.
*/
sealed interface NewsFeedUiState {
/**
* The feed is still loading.
*/
object Loading : NewsFeedUiState
/**
* The feed is loaded with the given list of news resources.
*/
data class Success(
/**
* The list of news resources contained in this feed.
*/
val feed: List<UserNewsResource>
) : NewsFeedUiState
}
@Preview
@Composable
private fun NewsFeedLoadingPreview() {
NiaTheme {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed(
feedState = NewsFeedUiState.Loading,
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
@Preview @Preview
@Preview(device = Devices.TABLET) @Preview(device = Devices.TABLET)
@Composable @Composable
private fun NewsFeedContentPreview() { private fun NewsFeedContentPreview() {
NiaTheme { NiaTheme {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) { LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed( items(
feedState = NewsFeedUiState.Success( items = previewUserNewsResources,
previewUserNewsResources itemContent = { userNewsResource ->
), NewsItem(
onNewsResourcesCheckedChanged = { _, _ -> } userNewsResource = userNewsResource,
onNewsResourcesCheckedChanged = { _, _ -> },
)
}
) )
} }
} }

@ -31,7 +31,6 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import org.junit.Rule import org.junit.Rule
@ -49,7 +48,7 @@ class BookmarksScreenTest {
fun loading_showsLoadingSpinner() { fun loading_showsLoadingSpinner() {
composeTestRule.setContent { composeTestRule.setContent {
BookmarksScreen( BookmarksScreen(
feedState = NewsFeedUiState.Loading, bookmarkItems = listOf(BookmarkItem.Loading),
removeFromBookmarks = { } removeFromBookmarks = { }
) )
} }
@ -65,9 +64,9 @@ class BookmarksScreenTest {
fun feed_whenHasBookmarks_showsBookmarks() { fun feed_whenHasBookmarks_showsBookmarks() {
composeTestRule.setContent { composeTestRule.setContent {
BookmarksScreen( BookmarksScreen(
feedState = NewsFeedUiState.Success( bookmarkItems = previewUserNewsResources
previewUserNewsResources.take(2) .take(2)
), .map(BookmarkItem::News),
removeFromBookmarks = { } removeFromBookmarks = { }
) )
} }
@ -103,9 +102,9 @@ class BookmarksScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BookmarksScreen( BookmarksScreen(
feedState = NewsFeedUiState.Success( bookmarkItems = previewUserNewsResources
previewUserNewsResources.take(2) .take(2)
), .map(BookmarkItem::News),
removeFromBookmarks = { newsResourceId -> removeFromBookmarks = { newsResourceId ->
assertEquals(previewUserNewsResources[0].id, newsResourceId) assertEquals(previewUserNewsResources[0].id, newsResourceId)
removeFromBookmarksCalled = true removeFromBookmarksCalled = true
@ -137,7 +136,7 @@ class BookmarksScreenTest {
fun feed_whenHasNoBookmarks_showsEmptyState() { fun feed_whenHasNoBookmarks_showsEmptyState() {
composeTestRule.setContent { composeTestRule.setContent {
BookmarksScreen( BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()), bookmarkItems = emptyList(),
removeFromBookmarks = { } removeFromBookmarks = { }
) )
} }

@ -0,0 +1,42 @@
/*
* 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.bookmarks
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarkItem.Loading
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarkItem.News
sealed class BookmarkItem {
object Loading : BookmarkItem()
data class News(
val userNewsResource: UserNewsResource
) : BookmarkItem()
}
val BookmarkItem.key: String
get() = when (this) {
Loading -> "loading"
is News -> userNewsResource.id
}
val BookmarkItem.contentType: String
get() = when (this) {
Loading -> "loading"
is News -> "news"
}

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -33,6 +34,7 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -53,11 +55,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsItem
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class) @OptIn(ExperimentalLifecycleComposeApi::class)
@Composable @Composable
@ -65,9 +64,9 @@ internal fun BookmarksRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel() viewModel: BookmarksViewModel = hiltViewModel()
) { ) {
val feedState by viewModel.feedUiState.collectAsStateWithLifecycle() val bookmarkItems by viewModel.bookmarkItems.collectAsStateWithLifecycle()
BookmarksScreen( BookmarksScreen(
feedState = feedState, bookmarkItems = bookmarkItems,
removeFromBookmarks = viewModel::removeFromSavedResources, removeFromBookmarks = viewModel::removeFromSavedResources,
modifier = modifier modifier = modifier
) )
@ -79,17 +78,17 @@ internal fun BookmarksRoute(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable @Composable
internal fun BookmarksScreen( internal fun BookmarksScreen(
feedState: NewsFeedUiState, bookmarkItems: List<BookmarkItem>,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (feedState) { when {
Loading -> LoadingState(modifier) bookmarkItems.isNotEmpty() -> BookmarksGrid(
is Success -> if (feedState.feed.isNotEmpty()) { bookmarkItems = bookmarkItems,
BookmarksGrid(feedState, removeFromBookmarks, modifier) removeFromBookmarks = removeFromBookmarks,
} else { modifier = modifier
EmptyState(modifier) )
} else -> EmptyState(modifier)
} }
} }
@ -104,9 +103,10 @@ private fun LoadingState(modifier: Modifier = Modifier) {
) )
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun BookmarksGrid( private fun BookmarksGrid(
feedState: NewsFeedUiState, bookmarkItems: List<BookmarkItem>,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -122,10 +122,22 @@ private fun BookmarksGrid(
.fillMaxSize() .fillMaxSize()
.testTag("bookmarks:feed") .testTag("bookmarks:feed")
) { ) {
newsFeed( items(
feedState = feedState, items = bookmarkItems,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, key = BookmarkItem::key,
contentType = BookmarkItem::contentType,
itemContent = { item ->
when (item) {
BookmarkItem.Loading -> LoadingState(modifier)
is BookmarkItem.News -> NewsItem(
modifier = Modifier.animateItemPlacement(),
userNewsResource = item.userNewsResource,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }
)
}
}
) )
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
} }
@ -182,9 +194,7 @@ private fun LoadingStatePreview() {
private fun BookmarksGridPreview() { private fun BookmarksGridPreview() {
NiaTheme { NiaTheme {
BookmarksGrid( BookmarksGrid(
feedState = Success( bookmarkItems = previewUserNewsResources.map(BookmarkItem::News),
previewUserNewsResources
),
removeFromBookmarks = {} removeFromBookmarks = {}
) )
} }

@ -18,36 +18,35 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
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
@HiltViewModel @HiltViewModel
class BookmarksViewModel @Inject constructor( class BookmarksViewModel @Inject constructor(
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase getUserNewsResources: GetUserNewsResourcesUseCase
) : ViewModel() { ) : ViewModel() {
val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResources() val bookmarkItems: StateFlow<List<BookmarkItem>> = getUserNewsResources(
NewsResourceQuery()
)
.filterNot { it.isEmpty() } .filterNot { it.isEmpty() }
.map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources. .map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources.
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success) .map<List<UserNewsResource>, List<BookmarkItem>> { it.map(BookmarkItem::News) }
.onStart { emit(Loading) }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = Loading initialValue = listOf(BookmarkItem.Loading)
) )
fun removeFromSavedResources(newsResourceId: String) { fun removeFromSavedResources(newsResourceId: String) {

@ -21,8 +21,6 @@ import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -53,31 +51,34 @@ class BookmarksViewModelTest {
fun setup() { fun setup() {
viewModel = BookmarksViewModel( viewModel = BookmarksViewModel(
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getSaveableNewsResources = getUserNewsResourcesUseCase getUserNewsResources = getUserNewsResourcesUseCase
) )
} }
@Test @Test
fun stateIsInitiallyLoading() = runTest { fun stateIsInitiallyLoading() = runTest {
assertEquals(Loading, viewModel.feedUiState.value) assertEquals(
expected = listOf(BookmarkItem.Loading),
actual = viewModel.bookmarkItems.value
)
} }
@Test @Test
fun oneBookmark_showsInFeed() = runTest { fun oneBookmark_showsInFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.bookmarkItems.collect() }
newsRepository.sendNewsResources(previewNewsResources) newsRepository.sendNewsResources(previewNewsResources)
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true) userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
val item = viewModel.feedUiState.value val item = viewModel.bookmarkItems.value.first()
assertIs<Success>(item) assertIs<BookmarkItem.News>(item)
assertEquals(item.feed.size, 1) assertEquals(viewModel.bookmarkItems.value.size, 1)
collectJob.cancel() collectJob.cancel()
} }
@Test @Test
fun oneBookmark_whenRemoving_removesFromFeed() = runTest { fun oneBookmark_whenRemoving_removesFromFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.bookmarkItems.collect() }
// Set the news resources to be used by this test // Set the news resources to be used by this test
newsRepository.sendNewsResources(previewNewsResources) newsRepository.sendNewsResources(previewNewsResources)
// Start with the resource saved // Start with the resource saved
@ -85,9 +86,10 @@ class BookmarksViewModelTest {
// Use viewModel to remove saved resource // Use viewModel to remove saved resource
viewModel.removeFromSavedResources(previewNewsResources[0].id) viewModel.removeFromSavedResources(previewNewsResources[0].id)
// Verify list of saved resources is now empty // Verify list of saved resources is now empty
val item = viewModel.feedUiState.value assertEquals(
assertIs<Success>(item) expected = emptyList(),
assertEquals(item.feed.size, 0) actual = viewModel.bookmarkItems.value
)
collectJob.cancel() collectJob.cancel()
} }

@ -31,7 +31,7 @@ import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.feature.foryou.ForYouItem.News.Loaded
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -51,8 +51,9 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Loading, forYouItems = listOf(
feedState = NewsFeedUiState.Loading, ForYouItem.OnBoarding(OnboardingUiState.Loading)
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -73,8 +74,7 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = true, isSyncing = true,
onboardingUiState = OnboardingUiState.NotShown, forYouItems = emptyList(),
feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -95,12 +95,12 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = forYouItems = listOf(
OnboardingUiState.Shown( ForYouItem.OnBoarding(
topics = testTopics, OnboardingUiState.Shown(
), topics = testTopics,
feedState = NewsFeedUiState.Success( )
feed = emptyList() )
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
@ -135,15 +135,15 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = forYouItems = listOf(
OnboardingUiState.Shown( ForYouItem.OnBoarding(
// Follow one topic OnboardingUiState.Shown(
topics = testTopics.mapIndexed { index, testTopic -> // Follow one topic
testTopic.copy(isFollowed = index == 1) topics = testTopics.mapIndexed { index, testTopic ->
} testTopic.copy(isFollowed = index == 1)
), }
feedState = NewsFeedUiState.Success( )
feed = emptyList() )
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
@ -178,9 +178,14 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = forYouItems = listOf(
OnboardingUiState.Shown(topics = testTopics), ForYouItem.OnBoarding(
feedState = NewsFeedUiState.Loading, OnboardingUiState.Shown(
topics = testTopics
)
),
ForYouItem.News.Loading
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -201,8 +206,7 @@ class ForYouScreenTest {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown, forYouItems = listOf(ForYouItem.News.Loading),
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -222,10 +226,7 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown, forYouItems = previewUserNewsResources.map(::Loaded),
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }

@ -0,0 +1,56 @@
/*
* 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.domain.model.UserNewsResource
/**
* Types of items that can show up in the "For you" grid
*/
sealed class ForYouItem {
data class OnBoarding(
val onboardingUiState: OnboardingUiState
) : ForYouItem()
sealed class News : ForYouItem() {
object Loading : News()
data class Loaded(
val userNewsResource: UserNewsResource
) : News()
}
}
val ForYouItem.key: String
get() = when (val item = this) {
is ForYouItem.News -> when (item) {
is ForYouItem.News.Loading -> LOADING_KEY
is ForYouItem.News.Loaded -> item.userNewsResource.id
}
is ForYouItem.OnBoarding -> ONBOARDING_KEY
}
val ForYouItem.contentType: String
get() = when (val item = this) {
is ForYouItem.News -> when (item) {
is ForYouItem.News.Loading -> "news-loading-item"
is ForYouItem.News.Loaded -> "news-loaded-item"
}
is ForYouItem.OnBoarding -> "onboarding-item"
}
private const val LOADING_KEY = "loading"
private const val ONBOARDING_KEY = "onboarding"

@ -22,6 +22,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@ -43,7 +44,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
@ -82,13 +83,10 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsItem
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class) @OptIn(ExperimentalLifecycleComposeApi::class)
@Composable @Composable
@ -96,33 +94,38 @@ internal fun ForYouRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel() viewModel: ForYouViewModel = hiltViewModel()
) { ) {
val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val forYouItems by viewModel.forYouItems.collectAsStateWithLifecycle()
val lazyGridState = rememberLazyGridState()
ForYouScreen( ForYouScreen(
isSyncing = isSyncing, isSyncing = isSyncing,
onboardingUiState = onboardingUiState, forYouItems = forYouItems,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
saveFollowedTopics = viewModel::dismissOnboarding, saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier modifier = modifier,
lazyGridState = lazyGridState
) )
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
internal fun ForYouScreen( internal fun ForYouScreen(
isSyncing: Boolean, isSyncing: Boolean,
onboardingUiState: OnboardingUiState, forYouItems: List<ForYouItem>,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
lazyGridState: LazyGridState = rememberLazyGridState(),
) { ) {
val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading val isOnboardingLoading = forYouItems.any {
val isFeedLoading = feedState is NewsFeedUiState.Loading it is ForYouItem.OnBoarding && it.onboardingUiState is OnboardingUiState.Loading
}
val isFeedLoading = forYouItems.any {
it is ForYouItem.News.Loading
}
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose. // Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// This code should be called when the UI is ready for use // This code should be called when the UI is ready for use
@ -142,8 +145,7 @@ internal fun ForYouScreen(
} }
} }
val state = rememberLazyGridState() TrackScrollJank(scrollableState = lazyGridState, stateName = "forYou:feed")
TrackScrollJank(scrollableState = state, stateName = "forYou:feed")
LazyVerticalGrid( LazyVerticalGrid(
columns = Adaptive(300.dp), columns = Adaptive(300.dp),
@ -153,39 +155,54 @@ internal fun ForYouScreen(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.testTag("forYou:feed"), .testTag("forYou:feed"),
state = state state = lazyGridState
) { ) {
onboarding( items(
onboardingUiState = onboardingUiState, items = forYouItems,
onTopicCheckedChanged = onTopicCheckedChanged, key = ForYouItem::key,
saveFollowedTopics = saveFollowedTopics, contentType = ForYouItem::contentType,
// Custom LayoutModifier to remove the enforced parent 16.dp contentPadding span = { item ->
// from the LazyVerticalGrid and enable edge-to-edge scrolling for this section when (item) {
interestsItemModifier = Modifier.layout { measurable, constraints -> is ForYouItem.OnBoarding -> GridItemSpan(maxLineSpan)
val placeable = measurable.measure( is ForYouItem.News -> GridItemSpan(1)
constraints.copy( }
maxWidth = constraints.maxWidth + 32.dp.roundToPx() },
itemContent = { item ->
when (item) {
is ForYouItem.News -> when (item) {
is ForYouItem.News.Loading -> Unit
is ForYouItem.News.Loaded -> NewsItem(
modifier = Modifier.animateItemPlacement(),
userNewsResource = item.userNewsResource,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
)
}
is ForYouItem.OnBoarding -> OnboardingItem(
onboardingUiState = item.onboardingUiState,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics,
// Custom LayoutModifier to remove the enforced parent 16.dp contentPadding
// from the LazyVerticalGrid and enable edge-to-edge scrolling for this section
interestsItemModifier = Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
maxWidth = constraints.maxWidth + 32.dp.roundToPx()
)
)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
.animateItemPlacement()
) )
)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
} }
} }
) )
item {
newsFeed( SpacerItem(
feedState = feedState, modifier = Modifier.animateItemPlacement(),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, )
)
item(span = { GridItemSpan(maxLineSpan) }) {
Column {
Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar.
// TODO: Check that the Scaffold handles this correctly in NiaApp
// if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
} }
AnimatedVisibility( AnimatedVisibility(
@ -216,7 +233,8 @@ internal fun ForYouScreen(
* Depending on the [onboardingUiState], this might emit no items. * Depending on the [onboardingUiState], this might emit no items.
* *
*/ */
private fun LazyGridScope.onboarding( @Composable
fun OnboardingItem(
onboardingUiState: OnboardingUiState, onboardingUiState: OnboardingUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
@ -228,45 +246,43 @@ private fun LazyGridScope.onboarding(
OnboardingUiState.NotShown -> Unit OnboardingUiState.NotShown -> Unit
is OnboardingUiState.Shown -> { is OnboardingUiState.Shown -> {
item(span = { GridItemSpan(maxLineSpan) }) { Column(modifier = interestsItemModifier) {
Column(modifier = interestsItemModifier) { Text(
Text( text = stringResource(R.string.onboarding_guidance_title),
text = stringResource(R.string.onboarding_guidance_title), textAlign = TextAlign.Center,
textAlign = TextAlign.Center, modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .padding(top = 24.dp),
.padding(top = 24.dp), style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium )
) Text(
Text( text = stringResource(R.string.onboarding_guidance_subtitle),
text = stringResource(R.string.onboarding_guidance_subtitle), modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
TopicSelection(
onboardingUiState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
NiaButton(
onClick = saveFollowedTopics,
enabled = onboardingUiState.isDismissable,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .padding(horizontal = 40.dp)
.padding(top = 8.dp, start = 16.dp, end = 16.dp), .width(364.dp)
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
TopicSelection(
onboardingUiState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) { ) {
NiaButton( Text(
onClick = saveFollowedTopics, text = stringResource(R.string.done)
enabled = onboardingUiState.isDismissable, )
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
) {
Text(
text = stringResource(R.string.done)
)
}
} }
} }
} }
@ -274,6 +290,17 @@ private fun LazyGridScope.onboarding(
} }
} }
@Composable
private fun SpacerItem(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar.
// TODO: Check that the Scaffold handles this correctly in NiaApp
// if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
@Composable @Composable
private fun TopicSelection( private fun TopicSelection(
onboardingUiState: OnboardingUiState.Shown, onboardingUiState: OnboardingUiState.Shown,
@ -393,10 +420,7 @@ fun ForYouScreenPopulatedFeed() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown, forYouItems = previewUserNewsResources.map(ForYouItem.News::Loaded),
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -412,10 +436,7 @@ fun ForYouScreenOfflinePopulatedFeed() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown, forYouItems = emptyList(),
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -431,12 +452,7 @@ fun ForYouScreenTopicSelection() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Shown( forYouItems = emptyList(),
topics = previewTopics.map { FollowableTopic(it, false) },
),
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -452,8 +468,7 @@ fun ForYouScreenLoading() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Loading, forYouItems = emptyList(),
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
@ -469,10 +484,7 @@ fun ForYouScreenPopulatedAndLoading() {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = true, isSyncing = true,
onboardingUiState = OnboardingUiState.Loading, forYouItems = emptyList(),
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }

@ -18,12 +18,12 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -33,7 +33,7 @@ import kotlinx.coroutines.flow.combine
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.shareIn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -41,12 +41,18 @@ import kotlinx.coroutines.launch
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor, syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
private val getSaveableNewsResources: GetUserNewsResourcesUseCase, getUserNewsResources: GetUserNewsResourcesUseCase,
getFollowableTopics: GetFollowableTopicsUseCase getFollowableTopics: GetFollowableTopicsUseCase
) : ViewModel() { ) : ViewModel() {
private val userData = userDataRepository.userData
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000)
)
private val shouldShowOnboarding: Flow<Boolean> = private val shouldShowOnboarding: Flow<Boolean> =
userDataRepository.userData.map { !it.shouldHideOnboarding } userData.map { !it.shouldHideOnboarding }
val isSyncing = syncStatusMonitor.isSyncing val isSyncing = syncStatusMonitor.isSyncing
.stateIn( .stateIn(
@ -55,47 +61,65 @@ class ForYouViewModel @Inject constructor(
initialValue = false initialValue = false
) )
val feedState: StateFlow<NewsFeedUiState> = private val onboardingItems =
userDataRepository.userData combine(
.map { userData -> shouldShowOnboarding,
// If the user hasn't completed the onboarding and hasn't selected any interests getFollowableTopics()
// show an empty news list to clearly demonstrate that their selections affect the ) { shouldShowOnboarding, topics ->
// news articles they will see. if (shouldShowOnboarding) OnboardingUiState.Shown(topics = topics)
if (!userData.shouldHideOnboarding && else OnboardingUiState.NotShown
userData.followedTopics.isEmpty() }
) { .map { onboardingUiState ->
flowOf(NewsFeedUiState.Success(emptyList())) // Add onboarding item if it should show
} else { if (onboardingUiState is OnboardingUiState.NotShown) emptyList()
getSaveableNewsResources( else listOf<ForYouItem>(ForYouItem.OnBoarding(onboardingUiState))
filterTopicIds = userData.followedTopics
).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.
.flatMapLatest { it }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading initialValue = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading),
)
) )
val onboardingUiState: StateFlow<OnboardingUiState> = private val newsItems = userData
combine( .flatMapLatest { userData ->
shouldShowOnboarding, // If the user hasn't completed the onboarding and hasn't selected any interests
getFollowableTopics() // show an empty news list to clearly demonstrate that their selections affect the
) { shouldShowOnboarding, topics -> // news articles they will see.
if (shouldShowOnboarding) { when {
OnboardingUiState.Shown(topics = topics) !userData.shouldHideOnboarding && userData.followedTopics.isEmpty() -> flowOf(
} else { emptyList()
OnboardingUiState.NotShown )
else -> getUserNewsResources(
NewsResourceQuery(filterTopicIds = userData.followedTopics)
)
} }
} }
.map { userNewsResources ->
userNewsResources.map<UserNewsResource, ForYouItem>(ForYouItem.News::Loaded)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = listOf(
ForYouItem.News.Loading,
)
)
val forYouItems: StateFlow<List<ForYouItem>> =
combine(
onboardingItems,
newsItems,
List<ForYouItem>::plus
)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = OnboardingUiState.Loading initialValue = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading),
ForYouItem.News.Loading,
)
) )
fun updateTopicSelection(topicId: String, isChecked: Boolean) { fun updateTopicSelection(topicId: String, isChecked: Boolean) {
@ -116,7 +140,3 @@ class ForYouViewModel @Inject constructor(
} }
} }
} }
private fun Flow<List<UserNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(NewsFeedUiState.Loading) }

@ -29,9 +29,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRe
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -51,7 +49,6 @@ class ForYouViewModelTest {
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val networkMonitor = TestNetworkMonitor()
private val syncStatusMonitor = TestSyncStatusMonitor() private val syncStatusMonitor = TestSyncStatusMonitor()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
@ -71,7 +68,7 @@ class ForYouViewModelTest {
viewModel = ForYouViewModel( viewModel = ForYouViewModel(
syncStatusMonitor = syncStatusMonitor, syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getSaveableNewsResources = getUserNewsResourcesUseCase, getUserNewsResources = getUserNewsResourcesUseCase,
getFollowableTopics = getFollowableTopicsUseCase getFollowableTopics = getFollowableTopicsUseCase
) )
} }
@ -79,28 +76,29 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsInitiallyLoading() = runTest { fun stateIsInitiallyLoading() = runTest {
assertEquals( assertEquals(
OnboardingUiState.Loading, expected = listOf(
viewModel.onboardingUiState.value ForYouItem.OnBoarding(OnboardingUiState.Loading),
ForYouItem.News.Loading,
),
actual = viewModel.forYouItems.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
} }
@Test @Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest { fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
assertEquals( assertEquals(
OnboardingUiState.Loading, expected = listOf(
viewModel.onboardingUiState.value ForYouItem.OnBoarding(OnboardingUiState.Loading),
ForYouItem.News.Loading
),
actual = viewModel.forYouItems.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
@Test @Test
@ -111,8 +109,8 @@ class ForYouViewModelTest {
launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() } launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }
assertEquals( assertEquals(
true, expected = true,
viewModel.isSyncing.value actual = viewModel.isSyncing.value
) )
collectJob.cancel() collectJob.cancel()
@ -120,149 +118,77 @@ class ForYouViewModelTest {
@Test @Test
fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest { fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
assertEquals( assertEquals(
OnboardingUiState.Loading, expected = listOf(
viewModel.onboardingUiState.value ForYouItem.OnBoarding(OnboardingUiState.Loading),
),
actual = viewModel.forYouItems.value
) )
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
@Test @Test
fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest { fun onboardingIsShownWhenTopicsArePresent() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
advanceUntilIdle()
assertEquals( assertEquals(
OnboardingUiState.Shown( expected = listOf(
topics = listOf( ForYouItem.OnBoarding(
FollowableTopic( OnboardingUiState.Shown(
topic = Topic( topics = sampleTopics.map {
id = "0", FollowableTopic(
name = "Headlines", topic = it,
shortDescription = "", isFollowed = false
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
),
), ),
), ),
viewModel.onboardingUiState.value actual = viewModel.forYouItems.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
) )
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
@Test @Test
fun onboardingIsShownAfterLoadingEmptyFollowedTopics() = runTest { fun onboardingIsShownWithNoNewsResourcesAfterLoadingEmptyFollowedTopics() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
assertEquals( assertEquals(
OnboardingUiState.Shown( expected = listOf(
topics = listOf( ForYouItem.OnBoarding(
FollowableTopic( OnboardingUiState.Shown(
topic = Topic( topics = sampleTopics.map {
id = "0", FollowableTopic(
name = "Headlines", topic = it,
shortDescription = "", isFollowed = false
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
),
), ),
), ),
viewModel.onboardingUiState.value actual = viewModel.forYouItems.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
) )
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
@Test @Test
fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest { fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -270,152 +196,139 @@ class ForYouViewModelTest {
val userData = emptyUserData.copy(followedTopics = followedTopicIds) val userData = emptyUserData.copy(followedTopics = followedTopicIds)
userDataRepository.setUserData(userData) userDataRepository.setUserData(userData)
viewModel.dismissOnboarding() viewModel.dismissOnboarding()
advanceUntilIdle()
assertEquals( assertEquals(
OnboardingUiState.NotShown, expected = listOf<ForYouItem>(ForYouItem.News.Loading),
viewModel.onboardingUiState.value actual = viewModel.forYouItems.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
advanceUntilIdle()
assertEquals( assertEquals(
OnboardingUiState.NotShown, expected = sampleNewsResources
viewModel.onboardingUiState.value .mapToUserNewsResources(userData)
) .map<UserNewsResource, ForYouItem>(ForYouItem.News::Loaded),
assertEquals( actual = viewModel.forYouItems.value
NewsFeedUiState.Success(
feed = sampleNewsResources.mapToUserNewsResources(userData)
),
viewModel.feedState.value
) )
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
@Test @Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest { fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
advanceUntilIdle()
assertEquals( assertEquals(
OnboardingUiState.Shown( expected = listOf(
topics = sampleTopics.map { ForYouItem.OnBoarding(
FollowableTopic(it, false) OnboardingUiState.Shown(
} topics = sampleTopics.map {
), FollowableTopic(
viewModel.onboardingUiState.value it, false
) )
assertEquals( }
NewsFeedUiState.Success( )
feed = emptyList(), ),
), ),
viewModel.feedState.value actual = viewModel.forYouItems.value
) )
val followedTopicId = sampleTopics[1].id val followedTopicId = sampleTopics[1].id
viewModel.updateTopicSelection(followedTopicId, isChecked = true) viewModel.updateTopicSelection(followedTopicId, isChecked = true)
advanceUntilIdle()
assertEquals(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(it, it.id == followedTopicId)
}
),
viewModel.onboardingUiState.value
)
val userData = emptyUserData.copy(followedTopics = setOf(followedTopicId)) val userData = emptyUserData.copy(followedTopics = setOf(followedTopicId))
assertEquals( assertEquals(
NewsFeedUiState.Success( expected = listOf(
feed = listOf( ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(it, it.id == followedTopicId)
},
)
),
ForYouItem.News.Loaded(
UserNewsResource(sampleNewsResources[1], userData), UserNewsResource(sampleNewsResources[1], userData),
),
ForYouItem.News.Loaded(
UserNewsResource(sampleNewsResources[2], userData), UserNewsResource(sampleNewsResources[2], userData),
) ),
), ),
viewModel.feedState.value actual = viewModel.forYouItems.value
) )
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
@Test @Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest { fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val followedTopicId = "1"
val userData = emptyUserData.copy(
followedTopics = setOf(followedTopicId)
)
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setUserData(userData)
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateTopicSelection("1", isChecked = true)
viewModel.updateTopicSelection("1", isChecked = false)
advanceUntilIdle() advanceUntilIdle()
assertEquals( assertEquals(
OnboardingUiState.Shown( expected = listOf<ForYouItem>(
topics = listOf( ForYouItem.OnBoarding(
FollowableTopic( OnboardingUiState.Shown(
topic = Topic( topics = sampleTopics.map {
id = "0", FollowableTopic(
name = "Headlines", topic = it,
shortDescription = "", isFollowed = it.id == followedTopicId
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
) )
), ),
), ) + sampleNewsResources
viewModel.onboardingUiState.value .filter { newsResource ->
newsResource.topics
.map(Topic::id)
.contains(followedTopicId)
}
.mapToUserNewsResources(userData)
.map(ForYouItem.News::Loaded),
actual = viewModel.forYouItems.value
) )
viewModel.updateTopicSelection("1", isChecked = false)
advanceUntilIdle()
assertEquals( assertEquals(
NewsFeedUiState.Success( expected = listOf<ForYouItem>(
feed = emptyList() ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(
topic = it,
isFollowed = false
)
},
)
),
), ),
viewModel.feedState.value actual = viewModel.forYouItems.value
) )
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
@Test @Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest { fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
val collectJob1 = val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val followedTopicIds = setOf("1") val followedTopicIds = setOf("1")
val userData = emptyUserData.copy( val userData = emptyUserData.copy(
@ -436,23 +349,18 @@ class ForYouViewModelTest {
val userDataExpected = userData.copy( val userDataExpected = userData.copy(
bookmarkedNewsResources = setOf(bookmarkedNewsResourceId) bookmarkedNewsResources = setOf(bookmarkedNewsResourceId)
) )
advanceUntilIdle()
assertEquals( assertEquals(
OnboardingUiState.NotShown, expected = listOf(
viewModel.onboardingUiState.value UserNewsResource(newsResource = sampleNewsResources[1], userDataExpected),
) UserNewsResource(newsResource = sampleNewsResources[2], userDataExpected),
assertEquals(
NewsFeedUiState.Success( ).map<UserNewsResource, ForYouItem>(ForYouItem.News::Loaded),
feed = listOf( actual = viewModel.forYouItems.value
UserNewsResource(newsResource = sampleNewsResources[1], userDataExpected),
UserNewsResource(newsResource = sampleNewsResources[2], userDataExpected)
)
),
viewModel.feedState.value
) )
collectJob1.cancel() collectJob.cancel()
collectJob2.cancel()
} }
} }

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
@ -64,7 +65,7 @@ class TopicViewModel @Inject constructor(
val newUiState: StateFlow<NewsUiState> = newsUiState( val newUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicArgs.topicId, topicId = topicArgs.topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResources getUserNewsResources = getSaveableNewsResources
) )
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -130,12 +131,14 @@ private fun topicUiState(
private fun newsUiState( private fun newsUiState(
topicId: String, topicId: String,
getSaveableNewsResources: GetUserNewsResourcesUseCase, getUserNewsResources: GetUserNewsResourcesUseCase,
userDataRepository: UserDataRepository, userDataRepository: UserDataRepository,
): Flow<NewsUiState> { ): Flow<NewsUiState> {
// Observe news // Observe news
val newsStream: Flow<List<UserNewsResource>> = getSaveableNewsResources( val newsStream: Flow<List<UserNewsResource>> = getUserNewsResources(
filterTopicIds = setOf(element = topicId), NewsResourceQuery(
filterTopicIds = setOf(element = topicId)
),
) )
// Observe bookmarks // Observe bookmarks

Loading…
Cancel
Save