diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22239cc8f..7f9fb1b30 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index b55c9b953..90d5d92dc 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -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() - /** * 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() + // 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" } diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index 597c17bcf..2814fdbc9 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -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 diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 3fb189b1e..92c0000e2 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -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( diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 076d8d762..dc620490a 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -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, diff --git a/benchmark/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt b/benchmark/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt index bd234dccb..4c0cb34dd 100644 --- a/benchmark/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt +++ b/benchmark/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt @@ -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() diff --git a/benchmark/src/main/java/com/google/samples/apps/nowinandroid/saved/SavedActions.kt b/benchmark/src/main/java/com/google/samples/apps/nowinandroid/saved/SavedActions.kt new file mode 100644 index 000000000..39bd0d965 --- /dev/null +++ b/benchmark/src/main/java/com/google/samples/apps/nowinandroid/saved/SavedActions.kt @@ -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) +} diff --git a/build_android_release.sh b/build_android_release.sh new file mode 100644 index 000000000..b0037c2bc --- /dev/null +++ b/build_android_release.sh @@ -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 \ No newline at end of file diff --git a/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/NewsResource.kt b/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/NewsResource.kt index c75c72ea7..fb3ee92d2 100644 --- a/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/NewsResource.kt +++ b/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/NewsResource.kt @@ -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! Here’s 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]) ) ) diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt new file mode 100644 index 000000000..c36ba72f1 --- /dev/null +++ b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -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 + ) : 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 = { _, _ -> } + ) + } + } +} diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index 3aabc7105..ac3de4363 100644 --- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -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" - ) - ) -) diff --git a/docs/ArchitectureLearningJourney.md b/docs/ArchitectureLearningJourney.md index f711284e0..b28c864dd 100644 --- a/docs/ArchitectureLearningJourney.md +++ b/docs/ArchitectureLearningJourney.md @@ -72,7 +72,7 @@ Here's what's happening in each step. The easiest way to find the associated cod The initial news feed state is set to Loading, which causes the UI to show a loading spinner on the screen. - Search for usages of ForYouFeedState.Loading + Search for usages of NewsFeedUiState.Loading @@ -138,7 +138,7 @@ Here's what's happening in each step. The easiest way to find the associated cod

The screen shows the newly retrieved news resources (as long as the user has chosen at least one topic or author). - Search for instances of ForYouFeedState.Success + Search for instances of NewsFeedUiState.Success @@ -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: diff --git a/feature-bookmarks/.gitignore b/feature-bookmarks/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature-bookmarks/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-bookmarks/build.gradle.kts b/feature-bookmarks/build.gradle.kts new file mode 100644 index 000000000..568ac3c54 --- /dev/null +++ b/feature-bookmarks/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/feature-bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature-bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt new file mode 100644 index 000000000..de79120cc --- /dev/null +++ b/feature-bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -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() + + @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) + } +} diff --git a/feature-bookmarks/src/main/AndroidManifest.xml b/feature-bookmarks/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a6641fae1 --- /dev/null +++ b/feature-bookmarks/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + \ No newline at end of file diff --git a/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt new file mode 100644 index 000000000..082a7e833 --- /dev/null +++ b/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -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)) + } + } + } + } + } +} diff --git a/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt new file mode 100644 index 000000000..73afb3031 --- /dev/null +++ b/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -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> = + userDataRepository.userDataStream + .map { userData -> + userData.bookmarkedNewsResources + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptySet() + ) + + val feedState: StateFlow = + newsRepository + .getNewsResourcesStream() + .mapToFeedState(savedNewsResourcesState) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = Loading + ) + + private fun Flow>.mapToFeedState( + savedNewsResourcesState: Flow> + ): Flow = + filterNot { it.isEmpty() } + .combine(savedNewsResourcesState) { newsResources, savedNewsResources -> + newsResources + .filter { newsResource -> savedNewsResources.contains(newsResource.id) } + .map { SaveableNewsResource(it, true) } + } + .map, NewsFeedUiState>(NewsFeedUiState::Success) + .onStart { emit(Loading) } + + fun removeFromSavedResources(newsResourceId: String) { + viewModelScope.launch { + userDataRepository.updateNewsResourceBookmark(newsResourceId, false) + } + } +} diff --git a/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt new file mode 100644 index 000000000..a97562a74 --- /dev/null +++ b/feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt @@ -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) + } +} diff --git a/feature-bookmarks/src/main/res/values/strings.xml b/feature-bookmarks/src/main/res/values/strings.xml new file mode 100644 index 000000000..bf59e2ec8 --- /dev/null +++ b/feature-bookmarks/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + + Saved + Loading saved… + Saved + Search + Menu + \ No newline at end of file diff --git a/feature-bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt b/feature-bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt new file mode 100644 index 000000000..9589f26ec --- /dev/null +++ b/feature-bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt @@ -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() + } +} diff --git a/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index bc54741e6..554928f84 100644 --- a/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -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 = { _, _ -> }, diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouFeedUiState.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouFeedUiState.kt deleted file mode 100644 index c6c6c345c..000000000 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouFeedUiState.kt +++ /dev/null @@ -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 - ) : ForYouFeedUiState -} diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 20c0f64bc..8a97d2336 100644 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -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 = {}, diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index 8f7a424f2..6c8dc3470 100644 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -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>(emptySet()) - } + private val savedNewsResourcesState: StateFlow> = + 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>(emptySet()) } - val feedState: StateFlow = + val feedState: StateFlow = 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.Loading) + Unknown -> flowOf(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.Success(emptyList())) + flowOf(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 = @@ -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>.mapToFeedState( - savedNewsResources: Set -): Flow = + savedNewsResourcesState: Flow> +): Flow = filterNot { it.isEmpty() } - .map { newsResources -> + .combine(savedNewsResourcesState) { newsResources, savedNewsResources -> newsResources.map { newsResource -> SaveableNewsResource( newsResource = newsResource, @@ -255,5 +250,5 @@ private fun Flow>.mapToFeedState( ) } } - .map, ForYouFeedUiState>(ForYouFeedUiState::Success) - .onStart { emit(ForYouFeedUiState.Loading) } + .map, NewsFeedUiState>(NewsFeedUiState::Success) + .onStart { emit(NewsFeedUiState.Loading) } diff --git a/feature-foryou/src/main/res/values/strings.xml b/feature-foryou/src/main/res/values/strings.xml index fb8b02307..7ed3683d9 100644 --- a/feature-foryou/src/main/res/values/strings.xml +++ b/feature-foryou/src/main/res/values/strings.xml @@ -17,7 +17,6 @@ For you Episodes - Saved Done Loading for you… Navigate up diff --git a/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index ca4ef7094..aadaa962b 100644 --- a/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -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], diff --git a/settings.gradle.kts b/settings.gradle.kts index d61d01798..3393b4879 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")