Merge pull request #197 from android/jul22-merge

Jul22 merge
pull/198/head
Don Turner 2 years ago committed by GitHub
commit 13d73e6f97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -96,6 +96,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"))

@ -46,26 +46,24 @@ class NavigationTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* Use the primary activity to initialize the app normally.
*/
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
/**
* Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash.
*/
@BindValue @get:Rule(order = 2)
@BindValue @get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
* Use the primary activity to initialize the app normally.
*/
@get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<MainActivity>()
// The strings used for matching in these tests
private lateinit var done: String
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"
}

@ -81,9 +81,10 @@ class NiaAppStateTest {
state = rememberNiaAppState(getCompactWindowClass())
}
assertEquals(2, state.topLevelDestinations.size)
assertEquals(3, state.topLevelDestinations.size)
assertTrue(state.topLevelDestinations[0].destination.contains("for_you"))
assertTrue(state.topLevelDestinations[1].destination.contains("interests"))
assertTrue(state.topLevelDestinations[1].destination.contains("bookmarks"))
assertTrue(state.topLevelDestinations[2].destination.contains("interests"))
}
@Test

@ -24,6 +24,7 @@ import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
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
@ -54,6 +55,7 @@ fun NiaNavHost(
forYouGraph(
windowSizeClass = windowSizeClass
)
bookmarksGraph(windowSizeClass)
interestsGraph(
navigateToTopic = {
onNavigateToDestination(

@ -34,6 +34,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVec
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksDestination
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR
@ -78,6 +80,13 @@ class NiaAppState(
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you
),
TopLevelDestination(
route = BookmarksDestination.route,
destination = BookmarksDestination.destination,
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved
),
TopLevelDestination(
route = InterestsDestination.route,
destination = InterestsDestination.destination,

@ -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)
}

@ -0,0 +1,65 @@
#!/usr/bin/env bash
#
# 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.
#
# IGNORE this file, it's only used in the internal Google release process
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_OUT=$DIR/app/build/outputs
export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"
echo "ANDROID_HOME=$ANDROID_HOME"
cd $DIR
# Build
GRADLE_PARAMS=" --stacktrace"
$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS}
BUILD_RESULT=$?
# Demo debug
cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR
# Demo release
cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR
# Prod debug
cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk
# Prod release
cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk
#cp $APP_OUT/mapping/release/mapping.txt $DIST_DIR/mobile-release-apk-mapping.txt
# Build App Bundles
# Don't clean here, otherwise all apks are gone.
$DIR/gradlew :app:bundle ${GRADLE_PARAMS}
# Demo debug
cp $APP_OUT/bundle/demoDebug/app-demo-debug.aab $DIST_DIR/app-demo-debug.aab
# Demo release
cp $APP_OUT/bundle/demoRelease/app-demo-release.aab $DIST_DIR/app-demo-release.aab
# Prod debug
cp $APP_OUT/bundle/prodDebug/app-prod-debug.aab $DIST_DIR/app-prod-debug.aab
# Prod release
cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab
#cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt
BUILD_RESULT=$?
exit $BUILD_RESULT

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.model.data
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@ -60,5 +61,35 @@ val previewNewsResources = listOf(
).toInstant(TimeZone.UTC),
type = Codelab,
topics = listOf(previewTopics[1])
),
NewsResource(
id = "2",
episodeId = "52",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
authors = listOf(previewAuthors[1]),
topics = listOf(previewTopics[0], previewTopics[1])
),
NewsResource(
id = "3",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
authors = listOf(previewAuthors[0], previewAuthors[1]),
topics = listOf(previewTopics[2])
)
)

@ -0,0 +1,203 @@
/*
* 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.core.ui
import android.content.Intent
import android.net.Uri
import androidx.annotation.IntRange
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
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.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
/**
* An extension on [LazyListScope] defining a feed with news resources.
* 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 allows a caller to suppress a loading visual if one is already
* present in the UI elsewhere.
*/
fun LazyListScope.NewsFeed(
feedState: NewsFeedUiState,
showLoadingUIIfLoading: Boolean,
@StringRes loadingContentDescription: Int,
@IntRange(from = 1) numberOfColumns: Int,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
) {
when (feedState) {
NewsFeedUiState.Loading -> {
if (showLoadingUIIfLoading) {
item {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(loadingContentDescription),
)
}
}
}
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
)
}
)
}
}
}
}
}
}
}
}
/**
* 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<SaveableNewsResource>
) : NewsFeedUiState
}
@Preview
@Composable
fun NewsFeedLoadingPreview() {
NiaTheme {
LazyColumn {
NewsFeed(
feedState = NewsFeedUiState.Loading,
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
numberOfColumns = 1,
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
@Preview
@Composable
fun NewsFeedSingleColumnPreview() {
NiaTheme {
LazyColumn {
NewsFeed(
feedState = NewsFeedUiState.Success(
previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
numberOfColumns = 1,
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
@Preview(device = Devices.TABLET)
@Composable
fun NewsFeedTwoColumnPreview() {
NiaTheme {
LazyColumn {
NewsFeed(
feedState = NewsFeedUiState.Success(
(previewNewsResources + previewNewsResources).map {
SaveableNewsResource(it, false)
}
),
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
numberOfColumns = 2,
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}

@ -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"
)
)
)

@ -72,7 +72,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
<td>The initial news feed state is set to <code>Loading</code>, which causes the UI to show a loading spinner on the screen.
</td>
<td>Search for usages of <code>ForYouFeedState.Loading</code>
<td>Search for usages of <code>NewsFeedUiState.Loading</code>
</td>
</tr>
<tr>
@ -138,7 +138,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
<p>
The screen shows the newly retrieved news resources (as long as the user has chosen at least one topic or author).
</td>
<td>Search for instances of <code>ForYouFeedState.Success</code>
<td>Search for instances of <code>NewsFeedUiState.Success</code>
</td>
</tr>
</table>
@ -260,7 +260,7 @@ UI state is modeled as a sealed hierarchy using interfaces and immutable data cl
**Example: News feed on For You screen**
The feed (a list) of news resources on the For You screen is modeled using `ForYouFeedState`. This is a sealed interface which creates a hierarchy of two possible states:
The feed (a list) of news resources on the For You screen is modeled using `NewsFeedUiState`. This is a sealed interface which creates a hierarchy of two possible states:

@ -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,23 @@
<?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">Saved</string>
<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()
}
}

@ -41,6 +41,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.datetime.Instant
import org.junit.Assert
import org.junit.Rule
@ -66,7 +67,7 @@ class ForYouScreenTest {
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = ForYouFeedUiState.Loading,
feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -95,7 +96,7 @@ class ForYouScreenTest {
topics = testTopics,
authors = testAuthors
),
feedState = ForYouFeedUiState.Success(
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
@ -149,7 +150,7 @@ class ForYouScreenTest {
},
authors = testAuthors
),
feedState = ForYouFeedUiState.Success(
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
@ -203,7 +204,7 @@ class ForYouScreenTest {
testAuthor.copy(isFollowed = index == 1)
}
),
feedState = ForYouFeedUiState.Success(
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
@ -254,7 +255,7 @@ class ForYouScreenTest {
topics = testTopics,
authors = testAuthors
),
feedState = ForYouFeedUiState.Loading,
feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -289,7 +290,7 @@ class ForYouScreenTest {
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = ForYouFeedUiState.Loading,
feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -328,7 +329,7 @@ class ForYouScreenTest {
ForYouScreen(
windowSizeClass = windowSizeClass,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = ForYouFeedUiState.Success(
feedState = NewsFeedUiState.Success(
feed = testNewsResources
),
onAuthorCheckedChanged = { _, _ -> },

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

@ -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
@ -67,7 +63,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
@ -80,7 +75,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 androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
@ -99,7 +93,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.NewsResourceCardExpanded
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.TrackScrollJank
import kotlin.math.floor
@ -129,7 +124,7 @@ fun ForYouRoute(
fun ForYouScreen(
windowSizeClass: WindowSizeClass,
interestsSelectionState: ForYouInterestsSelectionUiState,
feedState: ForYouFeedUiState,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
@ -177,7 +172,7 @@ fun ForYouScreen(
// and relates to Time To Full Display.
val interestsLoaded =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading
val feedLoaded = feedState !is ForYouFeedUiState.Loading
val feedLoaded = feedState !is NewsFeedUiState.Loading
if (interestsLoaded && feedLoaded) {
val localView = LocalView.current
@ -212,14 +207,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 {
@ -432,89 +428,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: ForYouFeedUiState,
showLoadingUIIfLoading: Boolean,
@IntRange(from = 1) numberOfColumns: Int,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
) {
when (feedState) {
ForYouFeedUiState.Loading -> {
if (showLoadingUIIfLoading) {
item {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
}
is ForYouFeedUiState.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")
@ -527,7 +440,7 @@ fun ForYouScreenPopulatedFeed() {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = ForYouFeedUiState.Success(
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
@ -556,7 +469,7 @@ fun ForYouScreenTopicSelection() {
topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) }
),
feedState = ForYouFeedUiState.Success(
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
@ -582,7 +495,7 @@ fun ForYouScreenLoading() {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = ForYouFeedUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},

@ -32,6 +32,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.FollowedInterests
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.None
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.Unknown
@ -77,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
@ -103,35 +105,33 @@ class ForYouViewModel @Inject constructor(
mutableStateOf<Set<String>>(emptySet())
}
val feedState: StateFlow<ForYouFeedUiState> =
val feedState: StateFlow<NewsFeedUiState> =
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<ForYouFeedUiState>(ForYouFeedUiState.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.
None -> {
if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
flowOf<ForYouFeedUiState>(ForYouFeedUiState.Success(emptyList()))
flowOf<NewsFeedUiState>(NewsFeedUiState.Success(emptyList()))
} else {
newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection
).mapToFeedState(savedNewsResources)
).mapToFeedState(savedNewsResourcesState)
}
}
}
@ -143,7 +143,7 @@ class ForYouViewModel @Inject constructor(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ForYouFeedUiState.Loading
initialValue = NewsFeedUiState.Loading
)
val interestsSelectionState: StateFlow<ForYouInterestsSelectionUiState> =
@ -215,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)
}
}
@ -244,10 +239,10 @@ class ForYouViewModel @Inject constructor(
}
private fun Flow<List<NewsResource>>.mapToFeedState(
savedNewsResources: Set<String>
): Flow<ForYouFeedUiState> =
savedNewsResourcesState: Flow<Set<String>>
): Flow<NewsFeedUiState> =
filterNot { it.isEmpty() }
.map { newsResources ->
.combine(savedNewsResourcesState) { newsResources, savedNewsResources ->
newsResources.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
@ -255,5 +250,5 @@ private fun Flow<List<NewsResource>>.mapToFeedState(
)
}
}
.map<List<SaveableNewsResource>, ForYouFeedUiState>(ForYouFeedUiState::Success)
.onStart { emit(ForYouFeedUiState.Loading) }
.map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(NewsFeedUiState.Loading) }

@ -17,7 +17,6 @@
<resources>
<string name="for_you">For you</string>
<string name="episodes">Episodes</string>
<string name="saved">Saved</string>
<string name="done">Done</string>
<string name="for_you_loading">Loading for you…</string>
<string name="navigate_up">Navigate up</string>

@ -29,6 +29,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
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
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -71,7 +72,7 @@ class ForYouViewModelTest {
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value
)
assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
}
@Test
@ -86,7 +87,7 @@ class ForYouViewModelTest {
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value
)
assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
@ -104,7 +105,7 @@ class ForYouViewModelTest {
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value
)
assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
@ -122,7 +123,7 @@ class ForYouViewModelTest {
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value
)
assertEquals(ForYouFeedUiState.Success(emptyList()), viewModel.feedState.value)
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
@ -140,7 +141,7 @@ class ForYouViewModelTest {
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value
)
assertEquals(ForYouFeedUiState.Success(emptyList()), viewModel.feedState.value)
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
@ -233,7 +234,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
@ -331,7 +332,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList()
),
@ -357,7 +358,7 @@ class ForYouViewModelTest {
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value
)
assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
newsRepository.sendNewsResources(sampleNewsResources)
@ -366,7 +367,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed =
sampleNewsResources.map {
SaveableNewsResource(
@ -398,7 +399,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Loading,
NewsFeedUiState.Loading,
viewModel.feedState.value
)
@ -409,7 +410,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = sampleNewsResources.map {
SaveableNewsResource(
newsResource = it,
@ -512,7 +513,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList(),
),
viewModel.feedState.value
@ -596,7 +597,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
@ -703,7 +704,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList(),
),
viewModel.feedState.value
@ -787,7 +788,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
@ -897,7 +898,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
@ -998,7 +999,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
@ -1028,7 +1029,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
@ -1069,7 +1070,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[0],
@ -1107,7 +1108,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
@ -1220,7 +1221,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
@ -1322,7 +1323,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
@ -1350,7 +1351,7 @@ class ForYouViewModelTest {
viewModel.interestsSelectionState.value
)
assertEquals(
ForYouFeedUiState.Success(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],

@ -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