[NiA] Add Saved functionality

Change-Id: I38320a16999d063a023bbe9d329a275af4e78b1e
pull/205/head
Jolanda Verhoef 2 years ago
parent dcd97fa6ac
commit c4110e33c6

@ -95,6 +95,7 @@ dependencies {
implementation(project(":feature-author"))
implementation(project(":feature-interests"))
implementation(project(":feature-foryou"))
implementation(project(":feature-bookmarks"))
implementation(project(":feature-topic"))
implementation(project(":core-ui"))

@ -64,8 +64,6 @@ class NavigationTest {
private lateinit var navigateUp: String
private lateinit var forYouLoading: String
private lateinit var forYou: String
private lateinit var episodes: String
private lateinit var saved: String
private lateinit var interests: String
private lateinit var sampleTopic: String
@ -76,8 +74,6 @@ class NavigationTest {
navigateUp = getString(R.string.navigate_up)
forYouLoading = getString(R.string.for_you_loading)
forYou = getString(R.string.for_you)
episodes = getString(R.string.episodes)
saved = getString(R.string.saved)
interests = getString(R.string.interests)
sampleTopic = "Headlines"
}

@ -24,6 +24,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
import com.google.samples.apps.nowinandroid.feature.author.navigation.authorGraph
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksGraph
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouGraph
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
@ -52,6 +53,7 @@ fun NiaNavHost(
forYouGraph(
windowSizeClass = windowSizeClass
)
bookmarksGraph(windowSizeClass)
interestsGraph(
navigateToTopic = { navController.navigate("${TopicDestination.route}/$it") },
navigateToAuthor = { navController.navigate("${AuthorDestination.route}/$it") },

@ -22,7 +22,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksDestination
import com.google.samples.apps.nowinandroid.feature.foryou.R.string.for_you
import com.google.samples.apps.nowinandroid.feature.foryou.R.string.saved
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.feature.interests.R.string.interests
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsDestination
@ -69,6 +71,12 @@ val TOP_LEVEL_DESTINATIONS = listOf(
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = for_you
),
TopLevelDestination(
route = BookmarksDestination.route,
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = saved
),
TopLevelDestination(
route = InterestsDestination.route,
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),

@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.foryou.forYouSelectAuthors
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import com.google.samples.apps.nowinandroid.saved.savedScrollFeedDownUp
import org.junit.Rule
import org.junit.Test
@ -50,6 +51,12 @@ class BaselineProfileGenerator {
forYouSelectAuthors()
forYouScrollFeedDownUp()
// Navigate to saved screen
device.findObject(By.text("Saved")).click()
device.waitForIdle()
savedScrollFeedDownUp()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()

@ -0,0 +1,34 @@
/*
* 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.saved
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
fun MacrobenchmarkScope.savedWaitForContent() {
// Wait until content is loaded
device.wait(Until.hasObject(By.res("saved:feed")), 30_000)
}
fun MacrobenchmarkScope.savedScrollFeedDownUp() {
val feedList = device.findObject(By.res("saved:feed"))
feedList.fling(Direction.DOWN)
device.waitForIdle()
feedList.fling(Direction.UP)
}

@ -61,8 +61,7 @@ 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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlinx.datetime.Instant
@ -112,8 +111,7 @@ fun NewsResourceCardExpanded(
modifier = Modifier.fillMaxWidth((.8f))
)
Spacer(modifier = Modifier.weight(1f))
// TODO: Implement functionality to 'bookmark' a resource b/227246491
// BookmarkButton(isBookmarked, onToggleBookmark)
BookmarkButton(isBookmarked, onToggleBookmark)
}
Spacer(modifier = Modifier.height(12.dp))
NewsResourceDate(newsResource.publishDate)
@ -297,38 +295,12 @@ fun BookmarkButtonBookmarkedPreview() {
fun ExpandedNewsResourcePreview() {
NiaTheme {
Surface {
NewsResourceCardExpanded(newsResource, true, {}, {})
NewsResourceCardExpanded(
newsResource = previewNewsResources[0],
isBookmarked = true,
onToggleBookmark = {},
onClick = {}
)
}
}
}
private val newsResource = NewsResource(
id = "1",
episodeId = "1",
title = "Title",
content = "Content",
url = "url",
headerImageUrl = "https://i.ytimg.com/vi/WL9h46CymlU/maxresdefault.jpg",
publishDate = Instant.DISTANT_FUTURE,
type = Article,
authors = listOf(
Author(
id = "1",
name = "Name",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
),
topics = listOf(
Topic(
id = "1",
name = "Name",
shortDescription = "Short description",
longDescription = "Long description",
url = "URL",
imageUrl = "image URL"
)
)
)

@ -0,0 +1 @@
/build

@ -0,0 +1,27 @@
/*
* 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.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
}
dependencies {
implementation(libs.androidx.compose.material3.windowSizeClass)
}

@ -0,0 +1,168 @@
/*
* 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 androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.unit.DpSize
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/**
* UI tests for [BookmarksScreen] composable.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class BookmarksScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun loading_showsLoadingSpinner() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent {
BoxWithConstraints {
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
)
BookmarksScreen(
windowSizeClass = windowSizeClass,
feedState = NewsFeedUiState.Loading,
removeFromBookmarks = { }
)
}
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.saved_loading)
)
.assertExists()
}
@Test
fun feed_whenHasBookmarks_showsBookmarks() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent {
BoxWithConstraints {
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
)
BookmarksScreen(
windowSizeClass = windowSizeClass,
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
),
removeFromBookmarks = { }
)
}
}
composeTestRule
.onNodeWithText(
previewNewsResources[0].title,
substring = true
)
.assertExists()
.assertHasClickAction()
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
previewNewsResources[1].title,
substring = true
)
.assertExists()
.assertHasClickAction()
}
@Test
fun feed_whenRemovingBookmark_removesBookmark() {
lateinit var windowSizeClass: WindowSizeClass
var removeFromBookmarksCalled = false
composeTestRule.setContent {
BoxWithConstraints {
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
)
BookmarksScreen(
windowSizeClass = windowSizeClass,
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
),
removeFromBookmarks = { newsResourceId ->
assertEquals(previewNewsResources[0].id, newsResourceId)
removeFromBookmarksCalled = true
}
)
}
}
composeTestRule
.onAllNodesWithContentDescription(
composeTestRule.activity.getString(
com.google.samples.apps.nowinandroid.core.ui.R.string.unbookmark
)
).filter(
hasAnyAncestor(
hasText(
previewNewsResources[0].title,
substring = true
)
)
)
.assertCountEquals(1)
.onFirst()
.performClick()
assertTrue(removeFromBookmarksCalled)
}
}

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.feature.bookmarks">
</manifest>

@ -0,0 +1,135 @@
/*
* 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 androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumedWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.NewsFeed
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.math.floor
@Composable
fun BookmarksRoute(
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel()
) {
val feedState by viewModel.feedState.collectAsState()
BookmarksScreen(
windowSizeClass = windowSizeClass,
feedState = feedState,
removeFromBookmarks = viewModel::removeFromSavedResources,
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun BookmarksScreen(
windowSizeClass: WindowSizeClass,
feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit,
modifier: Modifier = Modifier
) {
NiaGradientBackground {
Scaffold(
topBar = {
NiaTopAppBar(
titleRes = R.string.top_app_bar_title_saved,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = R.string.top_app_bar_action_search
),
actionIcon = NiaIcons.AccountCircle,
actionIconContentDescription = stringResource(
id = R.string.top_app_bar_action_menu
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
},
containerColor = Color.Transparent
) { innerPadding ->
// TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed:
// https://issuetracker.google.com/issues/230514914
// https://issuetracker.google.com/issues/231320714
BoxWithConstraints(
modifier = modifier
.padding(innerPadding)
.consumedWindowInsets(innerPadding)
) {
val numberOfColumns = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> 1
else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.testTag("saved:feed"),
contentPadding = PaddingValues(bottom = 16.dp)
) {
NewsFeed(
feedState = feedState,
numberOfColumns = numberOfColumns,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
showLoadingUIIfLoading = true,
loadingContentDescription = R.string.saved_loading
)
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
}
}
}

@ -0,0 +1,82 @@
/*
* 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 androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.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.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class BookmarksViewModel @Inject constructor(
newsRepository: NewsRepository,
private val userDataRepository: UserDataRepository
) : ViewModel() {
private val savedNewsResourcesState: StateFlow<Set<String>> =
userDataRepository.userDataStream
.map { userData ->
userData.bookmarkedNewsResources
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet()
)
val feedState: StateFlow<NewsFeedUiState> =
newsRepository
.getNewsResourcesStream()
.mapToFeedState(savedNewsResourcesState)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Loading
)
private fun Flow<List<NewsResource>>.mapToFeedState(
savedNewsResourcesState: Flow<Set<String>>
): Flow<NewsFeedUiState> =
filterNot { it.isEmpty() }
.combine(savedNewsResourcesState) { newsResources, savedNewsResources ->
newsResources
.filter { newsResource -> savedNewsResources.contains(newsResource.id) }
.map { SaveableNewsResource(it, true) }
}
.map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(Loading) }
fun removeFromSavedResources(newsResourceId: String) {
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, false)
}
}
}

@ -0,0 +1,36 @@
/*
* 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.navigation
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
object BookmarksDestination : NiaNavigationDestination {
override val route = "bookmarks_route"
override val destination = "bookmarks_destination"
}
fun NavGraphBuilder.bookmarksGraph(
windowSizeClass: WindowSizeClass
) {
composable(route = BookmarksDestination.route) {
BookmarksRoute(windowSizeClass)
}
}

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<resources>
<string name="saved_loading">Loading saved…</string>
<string name="top_app_bar_title_saved">Saved</string>
<string name="top_app_bar_action_search">Search</string>
<string name="top_app_bar_action_menu">Menu</string>
</resources>

@ -0,0 +1,89 @@
/*
* 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.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 kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class BookmarksViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository()
private lateinit var viewModel: BookmarksViewModel
@Before
fun setup() {
viewModel = BookmarksViewModel(
userDataRepository = userDataRepository,
newsRepository = newsRepository
)
}
@Test
fun stateIsInitiallyLoading() = runTest {
assertEquals(Loading, viewModel.feedState.value)
}
@Test
fun oneBookmark_showsInFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
newsRepository.sendNewsResources(previewNewsResources)
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
val item = viewModel.feedState.value
assertTrue(item is Success)
assertEquals((item as Success).feed.size, 1)
collectJob.cancel()
}
@Test
fun oneBookmark_whenRemoving_removesFromFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
// Set the news resources to be used by this test
newsRepository.sendNewsResources(previewNewsResources)
// Start with the resource saved
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
// Use viewModel to remove saved resource
viewModel.removeFromSavedResources(previewNewsResources[0].id)
// Verify list of saved resources is now empty
val item = viewModel.feedState.value
assertTrue(item is Success)
assertEquals((item as Success).feed.size, 0)
collectJob.cancel()
}
}

@ -17,11 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.annotation.IntRange
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
@ -66,7 +62,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
@ -79,7 +74,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
@ -96,8 +90,8 @@ import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.NewsFeed
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded
import kotlin.math.floor
@Composable
@ -202,14 +196,15 @@ fun ForYouScreen(
saveFollowedTopics = saveFollowedTopics
)
Feed(
NewsFeed(
feedState = feedState,
// Avoid showing a second loading wheel if we already are for the interests
// selection
showLoadingUIIfLoading =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading,
numberOfColumns = numberOfColumns,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
loadingContentDescription = R.string.for_you_loading
)
item {
@ -418,89 +413,6 @@ fun TopicIcon(
)
}
/**
* An extension on [LazyListScope] defining the feed portion of the for you screen.
* Depending on the [feedState], this might emit no items.
*
* @param showLoadingUIIfLoading if true, show a visual indication of loading if the
* [feedState] is loading. This is controllable to permit du-duplicating loading
* states.
*/
private fun LazyListScope.Feed(
feedState: NewsFeedUiState,
showLoadingUIIfLoading: Boolean,
@IntRange(from = 1) numberOfColumns: Int,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
) {
when (feedState) {
NewsFeedUiState.Loading -> {
if (showLoadingUIIfLoading) {
item {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
}
is NewsFeedUiState.Success -> {
items(
feedState.feed.chunked(numberOfColumns)
) { saveableNewsResources ->
Row(
modifier = Modifier.padding(
top = 32.dp,
start = 16.dp,
end = 16.dp
),
horizontalArrangement = Arrangement.spacedBy(32.dp)
) {
// The last row may not be complete, but for a consistent grid
// structure we still want an element taking up the empty space.
// Therefore, the last row may have empty boxes.
repeat(numberOfColumns) { index ->
Box(
modifier = Modifier.weight(1f)
) {
val saveableNewsResource =
saveableNewsResources.getOrNull(index)
if (saveableNewsResource != null) {
val launchResourceIntent =
Intent(
Intent.ACTION_VIEW,
Uri.parse(saveableNewsResource.newsResource.url)
)
val context = LocalContext.current
NewsResourceCardExpanded(
newsResource = saveableNewsResource.newsResource,
isBookmarked = saveableNewsResource.isSaved,
onClick = {
ContextCompat.startActivity(
context,
launchResourceIntent,
null
)
},
onToggleBookmark = {
onNewsResourcesCheckedChanged(
saveableNewsResource.newsResource.id,
!saveableNewsResource.isSaved
)
}
)
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")

@ -78,15 +78,16 @@ class ForYouViewModel @Inject constructor(
initialValue = Unknown
)
/**
* TODO: Temporary saving of news resources persisted through process death with a
* [SavedStateHandle].
*
* This should be persisted to disk instead.
*/
private var savedNewsResources by savedStateHandle.saveable {
mutableStateOf<Set<String>>(emptySet())
}
private val savedNewsResourcesState: StateFlow<Set<String>> =
userDataRepository.userDataStream
.map { userData ->
userData.bookmarkedNewsResources
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet()
)
/**
* The in-progress set of topics to be selected, persisted through process death with a
@ -108,20 +109,18 @@ class ForYouViewModel @Inject constructor(
combine(
followedInterestsState,
snapshotFlow { inProgressTopicSelection },
snapshotFlow { inProgressAuthorSelection },
snapshotFlow { savedNewsResources }
) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection,
savedNewsResources ->
snapshotFlow { inProgressAuthorSelection }
) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection ->
when (followedInterestsUserState) {
// If we don't know the current selection state, emit loading.
Unknown -> flowOf<NewsFeedUiState>(NewsFeedUiState.Loading)
// If the user has followed topics, use those followed topics to populate the feed
is FollowedInterests -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = followedInterestsUserState.topicIds,
filterAuthorIds = followedInterestsUserState.authorIds
).mapToFeedState(savedNewsResources)
).mapToFeedState(savedNewsResourcesState)
}
// If the user hasn't followed interests yet, show a realtime populated feed based
// on the in-progress interests selections, if there are any.
@ -132,7 +131,7 @@ class ForYouViewModel @Inject constructor(
newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection
).mapToFeedState(savedNewsResources)
).mapToFeedState(savedNewsResourcesState)
}
}
}
@ -216,13 +215,8 @@ class ForYouViewModel @Inject constructor(
}
fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) {
withMutableSnapshot {
savedNewsResources =
if (isChecked) {
savedNewsResources + newsResourceId
} else {
savedNewsResources - newsResourceId
}
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, isChecked)
}
}
@ -245,10 +239,10 @@ class ForYouViewModel @Inject constructor(
}
private fun Flow<List<NewsResource>>.mapToFeedState(
savedNewsResources: Set<String>
savedNewsResourcesState: Flow<Set<String>>
): Flow<NewsFeedUiState> =
filterNot { it.isEmpty() }
.map { newsResources ->
.combine(savedNewsResourcesState) { newsResources, savedNewsResources ->
newsResources.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,

@ -58,6 +58,7 @@ include(":core-testing")
include(":feature-author")
include(":feature-foryou")
include(":feature-interests")
include(":feature-bookmarks")
include(":feature-topic")
include(":lint")
include(":sync")

Loading…
Cancel
Save