From fa9407c1e733b34a1fd7709d14f65a2d3ccfd9df Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Thu, 7 Jul 2022 13:01:17 +0200 Subject: [PATCH 1/3] Add automated build script for signed releases Change-Id: I3667cd53c1d9040205f016260b5dd3417a3bf318 --- build_android_release.sh | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 build_android_release.sh 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 From dcd97fa6ac5445716614b8bfc79e2f49a825eded Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 12 Jul 2022 15:57:51 +0200 Subject: [PATCH 2/3] [NiA] Extract feed code into core ui so it can be reused for saved tab Change-Id: Ie5bc9741d15481585fd715c069a4133dca3db977 --- .../core/model/data/NewsResource.kt | 31 +++ .../apps/nowinandroid/core/ui/NewsFeed.kt | 203 ++++++++++++++++++ docs/ArchitectureLearningJourney.md | 6 +- .../feature/foryou/ForYouScreenTest.kt | 15 +- .../feature/foryou/ForYouFeedUiState.kt | 39 ---- .../feature/foryou/ForYouScreen.kt | 17 +- .../feature/foryou/ForYouViewModel.kt | 15 +- .../feature/foryou/ForYouViewModelTest.kt | 47 ++-- 8 files changed, 286 insertions(+), 87 deletions(-) create mode 100644 core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt delete mode 100644 feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouFeedUiState.kt 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/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-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 7a607be0c..5a0706434 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 @@ -96,6 +96,7 @@ 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.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded import kotlin.math.floor @@ -124,7 +125,7 @@ fun ForYouRoute( fun ForYouScreen( windowSizeClass: WindowSizeClass, interestsSelectionState: ForYouInterestsSelectionUiState, - feedState: ForYouFeedUiState, + feedState: NewsFeedUiState, onTopicCheckedChanged: (String, Boolean) -> Unit, onAuthorCheckedChanged: (String, Boolean) -> Unit, saveFollowedTopics: () -> Unit, @@ -172,7 +173,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 @@ -426,13 +427,13 @@ fun TopicIcon( * states. */ private fun LazyListScope.Feed( - feedState: ForYouFeedUiState, + feedState: NewsFeedUiState, showLoadingUIIfLoading: Boolean, @IntRange(from = 1) numberOfColumns: Int, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit ) { when (feedState) { - ForYouFeedUiState.Loading -> { + NewsFeedUiState.Loading -> { if (showLoadingUIIfLoading) { item { NiaLoadingWheel( @@ -444,7 +445,7 @@ private fun LazyListScope.Feed( } } } - is ForYouFeedUiState.Success -> { + is NewsFeedUiState.Success -> { items( feedState.feed.chunked(numberOfColumns) ) { saveableNewsResources -> @@ -512,7 +513,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) } @@ -541,7 +542,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) } @@ -567,7 +568,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..bc2834c2b 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 @@ -103,7 +104,7 @@ class ForYouViewModel @Inject constructor( mutableStateOf>(emptySet()) } - val feedState: StateFlow = + val feedState: StateFlow = combine( followedInterestsState, snapshotFlow { inProgressTopicSelection }, @@ -114,7 +115,7 @@ class ForYouViewModel @Inject constructor( 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( @@ -126,7 +127,7 @@ class ForYouViewModel @Inject constructor( // 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, @@ -143,7 +144,7 @@ class ForYouViewModel @Inject constructor( .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = ForYouFeedUiState.Loading + initialValue = NewsFeedUiState.Loading ) val interestsSelectionState: StateFlow = @@ -245,7 +246,7 @@ class ForYouViewModel @Inject constructor( private fun Flow>.mapToFeedState( savedNewsResources: Set -): Flow = +): Flow = filterNot { it.isEmpty() } .map { newsResources -> newsResources.map { newsResource -> @@ -255,5 +256,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/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], From c4110e33c680e9def084b6dbf0c13d260301b78d Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 12 Jul 2022 15:01:05 +0200 Subject: [PATCH 3/3] [NiA] Add Saved functionality Change-Id: I38320a16999d063a023bbe9d329a275af4e78b1e --- app/build.gradle.kts | 1 + .../apps/nowinandroid/ui/NavigationTest.kt | 4 - .../nowinandroid/navigation/NiaNavHost.kt | 2 + .../navigation/NiaTopLevelNavigation.kt | 8 + .../BaselineProfileGenerator.kt | 7 + .../apps/nowinandroid/saved/SavedActions.kt | 34 ++++ .../nowinandroid/core/ui/NewsResourceCard.kt | 44 +---- feature-bookmarks/.gitignore | 1 + feature-bookmarks/build.gradle.kts | 27 +++ .../feature/bookmarks/BookmarksScreenTest.kt | 168 ++++++++++++++++++ .../src/main/AndroidManifest.xml | 20 +++ .../feature/bookmarks/BookmarksScreen.kt | 135 ++++++++++++++ .../feature/bookmarks/BookmarksViewModel.kt | 82 +++++++++ .../navigation/BookmarksNavigation.kt | 36 ++++ .../src/main/res/values/strings.xml | 22 +++ .../bookmarks/BookmarksViewModelTest.kt | 89 ++++++++++ .../feature/foryou/ForYouScreen.kt | 96 +--------- .../feature/foryou/ForYouViewModel.kt | 44 ++--- settings.gradle.kts | 1 + 19 files changed, 664 insertions(+), 157 deletions(-) create mode 100644 benchmark/src/main/java/com/google/samples/apps/nowinandroid/saved/SavedActions.kt create mode 100644 feature-bookmarks/.gitignore create mode 100644 feature-bookmarks/build.gradle.kts create mode 100644 feature-bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt create mode 100644 feature-bookmarks/src/main/AndroidManifest.xml create mode 100644 feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt create mode 100644 feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt create mode 100644 feature-bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt create mode 100644 feature-bookmarks/src/main/res/values/strings.xml create mode 100644 feature-bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt 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 5a0706434..ccc871e46 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")