diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4fae0d3f..0389044e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,6 +95,7 @@ dependencies { implementation(project(":feature-author")) implementation(project(":feature-interests")) implementation(project(":feature-foryou")) + implementation(project(":feature-bookmarks")) implementation(project(":feature-topic")) implementation(project(":core-ui")) 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..b68211eb1 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 @@ -64,8 +64,6 @@ class NavigationTest { private lateinit var navigateUp: String private lateinit var forYouLoading: String private lateinit var forYou: String - private lateinit var episodes: String - private lateinit var saved: String private lateinit var interests: String private lateinit var sampleTopic: String @@ -76,8 +74,6 @@ class NavigationTest { navigateUp = getString(R.string.navigate_up) forYouLoading = getString(R.string.for_you_loading) forYou = getString(R.string.for_you) - episodes = getString(R.string.episodes) - saved = getString(R.string.saved) interests = getString(R.string.interests) sampleTopic = "Headlines" } 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 0967ec363..76e28ce22 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 androidx.navigation.compose.rememberNavController import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination import com.google.samples.apps.nowinandroid.feature.author.navigation.authorGraph +import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksGraph import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph @@ -52,6 +53,7 @@ fun NiaNavHost( forYouGraph( windowSizeClass = windowSizeClass ) + bookmarksGraph(windowSizeClass) interestsGraph( navigateToTopic = { navController.navigate("${TopicDestination.route}/$it") }, navigateToAuthor = { navController.navigate("${AuthorDestination.route}/$it") }, diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt index 6023982e8..e34f0922d 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt @@ -22,7 +22,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons +import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksDestination import com.google.samples.apps.nowinandroid.feature.foryou.R.string.for_you +import com.google.samples.apps.nowinandroid.feature.foryou.R.string.saved import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination import com.google.samples.apps.nowinandroid.feature.interests.R.string.interests import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsDestination @@ -69,6 +71,12 @@ val TOP_LEVEL_DESTINATIONS = listOf( unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder), iconTextId = for_you ), + TopLevelDestination( + route = BookmarksDestination.route, + selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks), + unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder), + iconTextId = saved + ), TopLevelDestination( route = InterestsDestination.route, selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), 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/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/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..10f83cae0 --- /dev/null +++ b/feature-bookmarks/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + 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/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 c8e36c641..455e5600a 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 @@ -66,7 +62,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag @@ -79,7 +74,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp import androidx.compose.ui.util.trace -import androidx.core.content.ContextCompat import androidx.core.view.doOnPreDraw import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage @@ -96,8 +90,8 @@ import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewTopics +import com.google.samples.apps.nowinandroid.core.ui.NewsFeed import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState -import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded import kotlin.math.floor @Composable @@ -202,14 +196,15 @@ fun ForYouScreen( saveFollowedTopics = saveFollowedTopics ) - Feed( + NewsFeed( feedState = feedState, // Avoid showing a second loading wheel if we already are for the interests // selection showLoadingUIIfLoading = interestsSelectionState !is ForYouInterestsSelectionUiState.Loading, numberOfColumns = numberOfColumns, - onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + loadingContentDescription = R.string.for_you_loading ) item { @@ -418,89 +413,6 @@ fun TopicIcon( ) } -/** - * An extension on [LazyListScope] defining the feed portion of the for you screen. - * Depending on the [feedState], this might emit no items. - * - * @param showLoadingUIIfLoading if true, show a visual indication of loading if the - * [feedState] is loading. This is controllable to permit du-duplicating loading - * states. - */ -private fun LazyListScope.Feed( - feedState: NewsFeedUiState, - showLoadingUIIfLoading: Boolean, - @IntRange(from = 1) numberOfColumns: Int, - onNewsResourcesCheckedChanged: (String, Boolean) -> Unit -) { - when (feedState) { - NewsFeedUiState.Loading -> { - if (showLoadingUIIfLoading) { - item { - NiaLoadingWheel( - modifier = Modifier - .fillMaxWidth() - .wrapContentSize(), - contentDesc = stringResource(id = R.string.for_you_loading), - ) - } - } - } - is NewsFeedUiState.Success -> { - items( - feedState.feed.chunked(numberOfColumns) - ) { saveableNewsResources -> - Row( - modifier = Modifier.padding( - top = 32.dp, - start = 16.dp, - end = 16.dp - ), - horizontalArrangement = Arrangement.spacedBy(32.dp) - ) { - // The last row may not be complete, but for a consistent grid - // structure we still want an element taking up the empty space. - // Therefore, the last row may have empty boxes. - repeat(numberOfColumns) { index -> - Box( - modifier = Modifier.weight(1f) - ) { - val saveableNewsResource = - saveableNewsResources.getOrNull(index) - - if (saveableNewsResource != null) { - val launchResourceIntent = - Intent( - Intent.ACTION_VIEW, - Uri.parse(saveableNewsResource.newsResource.url) - ) - val context = LocalContext.current - - NewsResourceCardExpanded( - newsResource = saveableNewsResource.newsResource, - isBookmarked = saveableNewsResource.isSaved, - onClick = { - ContextCompat.startActivity( - context, - launchResourceIntent, - null - ) - }, - onToggleBookmark = { - onNewsResourcesCheckedChanged( - saveableNewsResource.newsResource.id, - !saveableNewsResource.isSaved - ) - } - ) - } - } - } - } - } - } - } -} - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") 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 bc2834c2b..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 @@ -78,15 +78,16 @@ class ForYouViewModel @Inject constructor( initialValue = Unknown ) - /** - * TODO: Temporary saving of news resources persisted through process death with a - * [SavedStateHandle]. - * - * This should be persisted to disk instead. - */ - private var savedNewsResources by savedStateHandle.saveable { - mutableStateOf>(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 @@ -108,20 +109,18 @@ class ForYouViewModel @Inject constructor( combine( followedInterestsState, snapshotFlow { inProgressTopicSelection }, - snapshotFlow { inProgressAuthorSelection }, - snapshotFlow { savedNewsResources } - ) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection, - savedNewsResources -> - + snapshotFlow { inProgressAuthorSelection } + ) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection -> when (followedInterestsUserState) { // If we don't know the current selection state, emit loading. Unknown -> flowOf(NewsFeedUiState.Loading) // If the user has followed topics, use those followed topics to populate the feed is FollowedInterests -> { + newsRepository.getNewsResourcesStream( filterTopicIds = followedInterestsUserState.topicIds, filterAuthorIds = followedInterestsUserState.authorIds - ).mapToFeedState(savedNewsResources) + ).mapToFeedState(savedNewsResourcesState) } // If the user hasn't followed interests yet, show a realtime populated feed based // on the in-progress interests selections, if there are any. @@ -132,7 +131,7 @@ class ForYouViewModel @Inject constructor( newsRepository.getNewsResourcesStream( filterTopicIds = inProgressTopicSelection, filterAuthorIds = inProgressAuthorSelection - ).mapToFeedState(savedNewsResources) + ).mapToFeedState(savedNewsResourcesState) } } } @@ -216,13 +215,8 @@ class ForYouViewModel @Inject constructor( } fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) { - withMutableSnapshot { - savedNewsResources = - if (isChecked) { - savedNewsResources + newsResourceId - } else { - savedNewsResources - newsResourceId - } + viewModelScope.launch { + userDataRepository.updateNewsResourceBookmark(newsResourceId, isChecked) } } @@ -245,10 +239,10 @@ class ForYouViewModel @Inject constructor( } private fun Flow>.mapToFeedState( - savedNewsResources: Set + savedNewsResourcesState: Flow> ): Flow = filterNot { it.isEmpty() } - .map { newsResources -> + .combine(savedNewsResourcesState) { newsResources, savedNewsResources -> newsResources.map { newsResource -> SaveableNewsResource( newsResource = newsResource, 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")