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
/**
* 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(
filterTopicIds: Set<String> = emptySet(),
query: NewsResourceQuery,
): Flow<List<NewsResource>>
}

@ -43,18 +43,13 @@ class OfflineFirstNewsRepository @Inject constructor(
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> =
newsResourceDao.getNewsResources()
override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
newsResourceDao.getNewsResources(
filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterTopicIds = query.filterTopicIds != null
)
.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) =
synchronizer.changeListSync(
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.model.asEntity
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.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -42,27 +43,21 @@ class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource
) : NewsRepository {
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>> =
override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
flow {
emit(
datasource
.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(NewsResourceEntity::asExternalModel)
)
}.flowOn(ioDispatcher)

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

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

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

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

@ -17,6 +17,7 @@
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.NewsResourceQuery
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.mapToUserNewsResources
@ -38,17 +39,13 @@ class GetUserNewsResourcesUseCase @Inject constructor(
/**
* 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
* this is empty the list of news resources will not be filtered.
* @param newsResourceQuery - Query parameters for the request.
*/
operator fun invoke(
filterTopicIds: Set<String> = emptySet()
newsResourceQuery: NewsResourceQuery
): Flow<List<UserNewsResource>> =
if (filterTopicIds.isEmpty()) {
newsRepository.getNewsResources()
} else {
newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
}.mapToUserNewsResources(userDataRepository.userData)
newsRepository.getNewsResources(query = newsResourceQuery)
.mapToUserNewsResources(userDataRepository.userData)
}
private fun Flow<List<NewsResource>>.mapToUserNewsResources(

@ -16,6 +16,7 @@
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.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
@ -45,7 +46,7 @@ class GetUserNewsResourcesUseCaseTest {
fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the user news resources stream.
val userNewsResources = useCase()
val userNewsResources = useCase(NewsResourceQuery())
// Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources)
@ -69,7 +70,11 @@ class GetUserNewsResourcesUseCaseTest {
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// 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.
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.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.Topic
import kotlinx.coroutines.channels.BufferOverflow
@ -33,14 +34,16 @@ class TestNewsRepository : NewsRepository {
private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override fun getNewsResources(): Flow<List<NewsResource>> = newsResourcesFlow
override fun getNewsResources(filterTopicIds: Set<String>): Flow<List<NewsResource>> =
getNewsResources().map { newsResources ->
newsResources.filter {
it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
newsResourcesFlow
.map { newsResources ->
when (val filterTopicIds = query.filterTopicIds) {
null -> newsResources
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.

@ -21,9 +21,7 @@ import android.net.Uri
import androidx.annotation.ColorInt
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.lazy.LazyListScope
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.items
import androidx.compose.material3.MaterialTheme
@ -31,6 +29,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
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
/**
* An extension on [LazyListScope] defining a feed with news resources.
* Depending on the [feedState], this might emit no items.
* Renders a [UserNewsResource] as a clickable card.
*/
fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
@Composable
fun NewsItem(
userNewsResource: UserNewsResource,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
when (feedState) {
NewsFeedUiState.Loading -> Unit
is NewsFeedUiState.Success -> {
items(feedState.feed, key = { it.id }) { userNewsResource ->
val resourceUrl by remember {
mutableStateOf(Uri.parse(userNewsResource.url))
}
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val resourceUrl by remember {
mutableStateOf(Uri.parse(userNewsResource.url))
}
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
NewsResourceCardExpanded(
userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved,
onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) },
onToggleBookmark = {
onNewsResourcesCheckedChanged(
userNewsResource.id,
!userNewsResource.isSaved
)
}
)
}
NewsResourceCardExpanded(
modifier = modifier,
userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved,
onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) },
onToggleBookmark = {
onNewsResourcesCheckedChanged(
userNewsResource.id,
!userNewsResource.isSaved
)
}
}
)
}
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)
}
/**
* 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(device = Devices.TABLET)
@Composable
private fun NewsFeedContentPreview() {
NiaTheme {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed(
feedState = NewsFeedUiState.Success(
previewUserNewsResources
),
onNewsResourcesCheckedChanged = { _, _ -> }
items(
items = previewUserNewsResources,
itemContent = { userNewsResource ->
NewsItem(
userNewsResource = userNewsResource,
onNewsResourcesCheckedChanged = { _, _ -> },
)
}
)
}
}

@ -31,7 +31,6 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
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.assertTrue
import org.junit.Rule
@ -49,7 +48,7 @@ class BookmarksScreenTest {
fun loading_showsLoadingSpinner() {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Loading,
bookmarkItems = listOf(BookmarkItem.Loading),
removeFromBookmarks = { }
)
}
@ -65,9 +64,9 @@ class BookmarksScreenTest {
fun feed_whenHasBookmarks_showsBookmarks() {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewUserNewsResources.take(2)
),
bookmarkItems = previewUserNewsResources
.take(2)
.map(BookmarkItem::News),
removeFromBookmarks = { }
)
}
@ -103,9 +102,9 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewUserNewsResources.take(2)
),
bookmarkItems = previewUserNewsResources
.take(2)
.map(BookmarkItem::News),
removeFromBookmarks = { newsResourceId ->
assertEquals(previewUserNewsResources[0].id, newsResourceId)
removeFromBookmarksCalled = true
@ -137,7 +136,7 @@ class BookmarksScreenTest {
fun feed_whenHasNoBookmarks_showsEmptyState() {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()),
bookmarkItems = emptyList(),
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
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
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.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.MaterialTheme
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.theme.NiaTheme
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.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
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.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
@ -65,9 +64,9 @@ internal fun BookmarksRoute(
modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel()
) {
val feedState by viewModel.feedUiState.collectAsStateWithLifecycle()
val bookmarkItems by viewModel.bookmarkItems.collectAsStateWithLifecycle()
BookmarksScreen(
feedState = feedState,
bookmarkItems = bookmarkItems,
removeFromBookmarks = viewModel::removeFromSavedResources,
modifier = modifier
)
@ -79,17 +78,17 @@ internal fun BookmarksRoute(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable
internal fun BookmarksScreen(
feedState: NewsFeedUiState,
bookmarkItems: List<BookmarkItem>,
removeFromBookmarks: (String) -> Unit,
modifier: Modifier = Modifier
) {
when (feedState) {
Loading -> LoadingState(modifier)
is Success -> if (feedState.feed.isNotEmpty()) {
BookmarksGrid(feedState, removeFromBookmarks, modifier)
} else {
EmptyState(modifier)
}
when {
bookmarkItems.isNotEmpty() -> BookmarksGrid(
bookmarkItems = bookmarkItems,
removeFromBookmarks = removeFromBookmarks,
modifier = modifier
)
else -> EmptyState(modifier)
}
}
@ -104,9 +103,10 @@ private fun LoadingState(modifier: Modifier = Modifier) {
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun BookmarksGrid(
feedState: NewsFeedUiState,
bookmarkItems: List<BookmarkItem>,
removeFromBookmarks: (String) -> Unit,
modifier: Modifier = Modifier
) {
@ -122,10 +122,22 @@ private fun BookmarksGrid(
.fillMaxSize()
.testTag("bookmarks:feed")
) {
newsFeed(
feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
items(
items = bookmarkItems,
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) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
@ -182,9 +194,7 @@ private fun LoadingStatePreview() {
private fun BookmarksGridPreview() {
NiaTheme {
BookmarksGrid(
feedState = Success(
previewUserNewsResources
),
bookmarkItems = previewUserNewsResources.map(BookmarkItem::News),
removeFromBookmarks = {}
)
}

@ -18,36 +18,35 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.lifecycle.ViewModel
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.domain.GetUserNewsResourcesUseCase
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 javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class BookmarksViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase
getUserNewsResources: GetUserNewsResourcesUseCase
) : ViewModel() {
val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResources()
val bookmarkItems: StateFlow<List<BookmarkItem>> = getUserNewsResources(
NewsResourceQuery()
)
.filterNot { it.isEmpty() }
.map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources.
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(Loading) }
.map<List<UserNewsResource>, List<BookmarkItem>> { it.map(BookmarkItem::News) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Loading
initialValue = listOf(BookmarkItem.Loading)
)
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.TestUserDataRepository
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.assertIs
import kotlinx.coroutines.flow.collect
@ -53,31 +51,34 @@ class BookmarksViewModelTest {
fun setup() {
viewModel = BookmarksViewModel(
userDataRepository = userDataRepository,
getSaveableNewsResources = getUserNewsResourcesUseCase
getUserNewsResources = getUserNewsResourcesUseCase
)
}
@Test
fun stateIsInitiallyLoading() = runTest {
assertEquals(Loading, viewModel.feedUiState.value)
assertEquals(
expected = listOf(BookmarkItem.Loading),
actual = viewModel.bookmarkItems.value
)
}
@Test
fun oneBookmark_showsInFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.bookmarkItems.collect() }
newsRepository.sendNewsResources(previewNewsResources)
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
val item = viewModel.feedUiState.value
assertIs<Success>(item)
assertEquals(item.feed.size, 1)
val item = viewModel.bookmarkItems.value.first()
assertIs<BookmarkItem.News>(item)
assertEquals(viewModel.bookmarkItems.value.size, 1)
collectJob.cancel()
}
@Test
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
newsRepository.sendNewsResources(previewNewsResources)
// Start with the resource saved
@ -85,9 +86,10 @@ class BookmarksViewModelTest {
// Use viewModel to remove saved resource
viewModel.removeFromSavedResources(previewNewsResources[0].id)
// Verify list of saved resources is now empty
val item = viewModel.feedUiState.value
assertIs<Success>(item)
assertEquals(item.feed.size, 0)
assertEquals(
expected = emptyList(),
actual = viewModel.bookmarkItems.value
)
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.previewUserNewsResources
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.Test
@ -51,8 +51,9 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
forYouItems = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading)
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -73,8 +74,7 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = true,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()),
forYouItems = emptyList(),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -95,12 +95,12 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(
topics = testTopics,
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
forYouItems = listOf(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = testTopics,
)
)
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -135,15 +135,15 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(
// Follow one topic
topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
}
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
forYouItems = listOf(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
// Follow one topic
topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
}
)
)
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -178,9 +178,14 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(topics = testTopics),
feedState = NewsFeedUiState.Loading,
forYouItems = listOf(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = testTopics
)
),
ForYouItem.News.Loading
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -201,8 +206,7 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading,
forYouItems = listOf(ForYouItem.News.Loading),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -222,10 +226,7 @@ class ForYouScreenTest {
composeTestRule.setContent {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
forYouItems = previewUserNewsResources.map(::Loaded),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
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.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.Adaptive
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.LazyVerticalGrid
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.icon.NiaIcons
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.model.data.previewTopics
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.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
@ -96,33 +94,38 @@ internal fun ForYouRoute(
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel()
) {
val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val forYouItems by viewModel.forYouItems.collectAsStateWithLifecycle()
val lazyGridState = rememberLazyGridState()
ForYouScreen(
isSyncing = isSyncing,
onboardingUiState = onboardingUiState,
feedState = feedState,
forYouItems = forYouItems,
onTopicCheckedChanged = viewModel::updateTopicSelection,
saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier
modifier = modifier,
lazyGridState = lazyGridState
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ForYouScreen(
isSyncing: Boolean,
onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState,
forYouItems: List<ForYouItem>,
onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
lazyGridState: LazyGridState = rememberLazyGridState(),
) {
val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading
val isFeedLoading = feedState is NewsFeedUiState.Loading
val isOnboardingLoading = forYouItems.any {
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.
// This code should be called when the UI is ready for use
@ -142,8 +145,7 @@ internal fun ForYouScreen(
}
}
val state = rememberLazyGridState()
TrackScrollJank(scrollableState = state, stateName = "forYou:feed")
TrackScrollJank(scrollableState = lazyGridState, stateName = "forYou:feed")
LazyVerticalGrid(
columns = Adaptive(300.dp),
@ -153,39 +155,54 @@ internal fun ForYouScreen(
modifier = modifier
.fillMaxSize()
.testTag("forYou:feed"),
state = state
state = lazyGridState
) {
onboarding(
onboardingUiState = 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()
items(
items = forYouItems,
key = ForYouItem::key,
contentType = ForYouItem::contentType,
span = { item ->
when (item) {
is ForYouItem.OnBoarding -> GridItemSpan(maxLineSpan)
is ForYouItem.News -> GridItemSpan(1)
}
},
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)
}
}
)
newsFeed(
feedState = feedState,
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))
}
item {
SpacerItem(
modifier = Modifier.animateItemPlacement(),
)
}
}
AnimatedVisibility(
@ -216,7 +233,8 @@ internal fun ForYouScreen(
* Depending on the [onboardingUiState], this might emit no items.
*
*/
private fun LazyGridScope.onboarding(
@Composable
fun OnboardingItem(
onboardingUiState: OnboardingUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
@ -228,45 +246,43 @@ private fun LazyGridScope.onboarding(
OnboardingUiState.NotShown -> Unit
is OnboardingUiState.Shown -> {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(modifier = interestsItemModifier) {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.onboarding_guidance_subtitle),
Column(modifier = interestsItemModifier) {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.onboarding_guidance_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp),
textAlign = TextAlign.Center,
style = 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
.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()
.padding(horizontal = 40.dp)
.width(364.dp)
) {
NiaButton(
onClick = saveFollowedTopics,
enabled = onboardingUiState.isDismissable,
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
) {
Text(
text = stringResource(R.string.done)
)
}
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
private fun TopicSelection(
onboardingUiState: OnboardingUiState.Shown,
@ -393,10 +420,7 @@ fun ForYouScreenPopulatedFeed() {
NiaTheme {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
forYouItems = previewUserNewsResources.map(ForYouItem.News::Loaded),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -412,10 +436,7 @@ fun ForYouScreenOfflinePopulatedFeed() {
NiaTheme {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
forYouItems = emptyList(),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -431,12 +452,7 @@ fun ForYouScreenTopicSelection() {
NiaTheme {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.Shown(
topics = previewTopics.map { FollowableTopic(it, false) },
),
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
forYouItems = emptyList(),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -452,8 +468,7 @@ fun ForYouScreenLoading() {
NiaTheme {
ForYouScreen(
isSyncing = false,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
forYouItems = emptyList(),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
@ -469,10 +484,7 @@ fun ForYouScreenPopulatedAndLoading() {
NiaTheme {
ForYouScreen(
isSyncing = true,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Success(
feed = previewUserNewsResources
),
forYouItems = emptyList(),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }

@ -18,12 +18,12 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.ViewModel
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.util.SyncStatusMonitor
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.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@ -33,7 +33,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -41,12 +41,18 @@ import kotlinx.coroutines.launch
class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository,
private val getSaveableNewsResources: GetUserNewsResourcesUseCase,
getUserNewsResources: GetUserNewsResourcesUseCase,
getFollowableTopics: GetFollowableTopicsUseCase
) : ViewModel() {
private val userData = userDataRepository.userData
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000)
)
private val shouldShowOnboarding: Flow<Boolean> =
userDataRepository.userData.map { !it.shouldHideOnboarding }
userData.map { !it.shouldHideOnboarding }
val isSyncing = syncStatusMonitor.isSyncing
.stateIn(
@ -55,47 +61,65 @@ class ForYouViewModel @Inject constructor(
initialValue = false
)
val feedState: StateFlow<NewsFeedUiState> =
userDataRepository.userData
.map { userData ->
// If the user hasn't completed the onboarding and hasn't selected any interests
// show an empty news list to clearly demonstrate that their selections affect the
// news articles they will see.
if (!userData.shouldHideOnboarding &&
userData.followedTopics.isEmpty()
) {
flowOf(NewsFeedUiState.Success(emptyList()))
} else {
getSaveableNewsResources(
filterTopicIds = userData.followedTopics
).mapToFeedState()
}
private val onboardingItems =
combine(
shouldShowOnboarding,
getFollowableTopics()
) { shouldShowOnboarding, topics ->
if (shouldShowOnboarding) OnboardingUiState.Shown(topics = topics)
else OnboardingUiState.NotShown
}
.map { onboardingUiState ->
// Add onboarding item if it should show
if (onboardingUiState is OnboardingUiState.NotShown) emptyList()
else listOf<ForYouItem>(ForYouItem.OnBoarding(onboardingUiState))
}
// 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 = NewsFeedUiState.Loading
initialValue = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading),
)
)
val onboardingUiState: StateFlow<OnboardingUiState> =
combine(
shouldShowOnboarding,
getFollowableTopics()
) { shouldShowOnboarding, topics ->
if (shouldShowOnboarding) {
OnboardingUiState.Shown(topics = topics)
} else {
OnboardingUiState.NotShown
private val newsItems = userData
.flatMapLatest { userData ->
// If the user hasn't completed the onboarding and hasn't selected any interests
// show an empty news list to clearly demonstrate that their selections affect the
// news articles they will see.
when {
!userData.shouldHideOnboarding && userData.followedTopics.isEmpty() -> flowOf(
emptyList()
)
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(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = OnboardingUiState.Loading
initialValue = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading),
ForYouItem.News.Loading,
)
)
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.emptyUserData
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.ui.NewsFeedUiState
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@ -51,7 +49,6 @@ class ForYouViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val networkMonitor = TestNetworkMonitor()
private val syncStatusMonitor = TestSyncStatusMonitor()
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
@ -71,7 +68,7 @@ class ForYouViewModelTest {
viewModel = ForYouViewModel(
syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository,
getSaveableNewsResources = getUserNewsResourcesUseCase,
getUserNewsResources = getUserNewsResourcesUseCase,
getFollowableTopics = getFollowableTopicsUseCase
)
}
@ -79,28 +76,29 @@ class ForYouViewModelTest {
@Test
fun stateIsInitiallyLoading() = runTest {
assertEquals(
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
expected = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading),
ForYouItem.News.Loading,
),
actual = viewModel.forYouItems.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
}
@Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
topicsRepository.sendTopics(sampleTopics)
assertEquals(
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
expected = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading),
ForYouItem.News.Loading
),
actual = viewModel.forYouItems.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
@Test
@ -111,8 +109,8 @@ class ForYouViewModelTest {
launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }
assertEquals(
true,
viewModel.isSyncing.value
expected = true,
actual = viewModel.isSyncing.value
)
collectJob.cancel()
@ -120,149 +118,77 @@ class ForYouViewModelTest {
@Test
fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
expected = listOf(
ForYouItem.OnBoarding(OnboardingUiState.Loading),
),
actual = viewModel.forYouItems.value
)
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
@Test
fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
fun onboardingIsShownWhenTopicsArePresent() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
advanceUntilIdle()
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
expected = listOf(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(
topic = it,
isFollowed = false
)
},
)
),
),
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
actual = viewModel.forYouItems.value
)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
@Test
fun onboardingIsShownAfterLoadingEmptyFollowedTopics() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
fun onboardingIsShownWithNoNewsResourcesAfterLoadingEmptyFollowedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
expected = listOf(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(
topic = it,
isFollowed = false
)
}
)
),
),
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
actual = viewModel.forYouItems.value
)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
@Test
fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
topicsRepository.sendTopics(sampleTopics)
@ -270,152 +196,139 @@ class ForYouViewModelTest {
val userData = emptyUserData.copy(followedTopics = followedTopicIds)
userDataRepository.setUserData(userData)
viewModel.dismissOnboarding()
advanceUntilIdle()
assertEquals(
OnboardingUiState.NotShown,
viewModel.onboardingUiState.value
expected = listOf<ForYouItem>(ForYouItem.News.Loading),
actual = viewModel.forYouItems.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
newsRepository.sendNewsResources(sampleNewsResources)
advanceUntilIdle()
assertEquals(
OnboardingUiState.NotShown,
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = sampleNewsResources.mapToUserNewsResources(userData)
),
viewModel.feedState.value
expected = sampleNewsResources
.mapToUserNewsResources(userData)
.map<UserNewsResource, ForYouItem>(ForYouItem.News::Loaded),
actual = viewModel.forYouItems.value
)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
@Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
advanceUntilIdle()
assertEquals(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(it, false)
}
),
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList(),
expected = listOf(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(
it, false
)
}
)
),
),
viewModel.feedState.value
actual = viewModel.forYouItems.value
)
val followedTopicId = sampleTopics[1].id
viewModel.updateTopicSelection(followedTopicId, isChecked = true)
assertEquals(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(it, it.id == followedTopicId)
}
),
viewModel.onboardingUiState.value
)
advanceUntilIdle()
val userData = emptyUserData.copy(followedTopics = setOf(followedTopicId))
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
expected = listOf(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(it, it.id == followedTopicId)
},
)
),
ForYouItem.News.Loaded(
UserNewsResource(sampleNewsResources[1], userData),
),
ForYouItem.News.Loaded(
UserNewsResource(sampleNewsResources[2], userData),
)
),
),
viewModel.feedState.value
actual = viewModel.forYouItems.value
)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
@Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
val followedTopicId = "1"
val userData = emptyUserData.copy(
followedTopics = setOf(followedTopicId)
)
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
userDataRepository.setUserData(userData)
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateTopicSelection("1", isChecked = true)
viewModel.updateTopicSelection("1", isChecked = false)
advanceUntilIdle()
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
expected = listOf<ForYouItem>(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(
topic = it,
isFollowed = it.id == followedTopicId
)
},
)
),
),
viewModel.onboardingUiState.value
) + sampleNewsResources
.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(
NewsFeedUiState.Success(
feed = emptyList()
expected = listOf<ForYouItem>(
ForYouItem.OnBoarding(
OnboardingUiState.Shown(
topics = sampleTopics.map {
FollowableTopic(
topic = it,
isFollowed = false
)
},
)
),
),
viewModel.feedState.value
actual = viewModel.forYouItems.value
)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
@Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.forYouItems.collect() }
val followedTopicIds = setOf("1")
val userData = emptyUserData.copy(
@ -436,23 +349,18 @@ class ForYouViewModelTest {
val userDataExpected = userData.copy(
bookmarkedNewsResources = setOf(bookmarkedNewsResourceId)
)
advanceUntilIdle()
assertEquals(
OnboardingUiState.NotShown,
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
UserNewsResource(newsResource = sampleNewsResources[1], userDataExpected),
UserNewsResource(newsResource = sampleNewsResources[2], userDataExpected)
)
),
viewModel.feedState.value
expected = listOf(
UserNewsResource(newsResource = sampleNewsResources[1], userDataExpected),
UserNewsResource(newsResource = sampleNewsResources[2], userDataExpected),
).map<UserNewsResource, ForYouItem>(ForYouItem.News::Loaded),
actual = viewModel.forYouItems.value
)
collectJob1.cancel()
collectJob2.cancel()
collectJob.cancel()
}
}

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

Loading…
Cancel
Save