* goog/main: [NiA] Add Saved functionality [NiA] Extract feed code into core ui so it can be reused for saved tab Add automated build script for signed releasespull/197/head
commit
686e425fbd
@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.saved
|
||||||
|
|
||||||
|
import androidx.benchmark.macro.MacrobenchmarkScope
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import androidx.test.uiautomator.Direction
|
||||||
|
import androidx.test.uiautomator.Until
|
||||||
|
|
||||||
|
fun MacrobenchmarkScope.savedWaitForContent() {
|
||||||
|
// Wait until content is loaded
|
||||||
|
device.wait(Until.hasObject(By.res("saved:feed")), 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MacrobenchmarkScope.savedScrollFeedDownUp() {
|
||||||
|
val feedList = device.findObject(By.res("saved:feed"))
|
||||||
|
feedList.fling(Direction.DOWN)
|
||||||
|
device.waitForIdle()
|
||||||
|
feedList.fling(Direction.UP)
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2022 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
# IGNORE this file, it's only used in the internal Google release process
|
||||||
|
|
||||||
|
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
APP_OUT=$DIR/app/build/outputs
|
||||||
|
|
||||||
|
export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"
|
||||||
|
|
||||||
|
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||||
|
cd $DIR
|
||||||
|
|
||||||
|
# Build
|
||||||
|
GRADLE_PARAMS=" --stacktrace"
|
||||||
|
$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS}
|
||||||
|
BUILD_RESULT=$?
|
||||||
|
|
||||||
|
# Demo debug
|
||||||
|
cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR
|
||||||
|
|
||||||
|
# Demo release
|
||||||
|
cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR
|
||||||
|
|
||||||
|
# Prod debug
|
||||||
|
cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk
|
||||||
|
|
||||||
|
# Prod release
|
||||||
|
cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk
|
||||||
|
#cp $APP_OUT/mapping/release/mapping.txt $DIST_DIR/mobile-release-apk-mapping.txt
|
||||||
|
|
||||||
|
# Build App Bundles
|
||||||
|
# Don't clean here, otherwise all apks are gone.
|
||||||
|
$DIR/gradlew :app:bundle ${GRADLE_PARAMS}
|
||||||
|
|
||||||
|
# Demo debug
|
||||||
|
cp $APP_OUT/bundle/demoDebug/app-demo-debug.aab $DIST_DIR/app-demo-debug.aab
|
||||||
|
|
||||||
|
# Demo release
|
||||||
|
cp $APP_OUT/bundle/demoRelease/app-demo-release.aab $DIST_DIR/app-demo-release.aab
|
||||||
|
|
||||||
|
# Prod debug
|
||||||
|
cp $APP_OUT/bundle/prodDebug/app-prod-debug.aab $DIST_DIR/app-prod-debug.aab
|
||||||
|
|
||||||
|
# Prod release
|
||||||
|
cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab
|
||||||
|
#cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt
|
||||||
|
BUILD_RESULT=$?
|
||||||
|
|
||||||
|
exit $BUILD_RESULT
|
@ -0,0 +1,203 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.core.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.IntRange
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Devices
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An extension on [LazyListScope] defining a feed with news resources.
|
||||||
|
* Depending on the [feedState], this might emit no items.
|
||||||
|
*
|
||||||
|
* @param showLoadingUIIfLoading if true, show a visual indication of loading if the
|
||||||
|
* [feedState] is loading. This allows a caller to suppress a loading visual if one is already
|
||||||
|
* present in the UI elsewhere.
|
||||||
|
*/
|
||||||
|
fun LazyListScope.NewsFeed(
|
||||||
|
feedState: NewsFeedUiState,
|
||||||
|
showLoadingUIIfLoading: Boolean,
|
||||||
|
@StringRes loadingContentDescription: Int,
|
||||||
|
@IntRange(from = 1) numberOfColumns: Int,
|
||||||
|
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
when (feedState) {
|
||||||
|
NewsFeedUiState.Loading -> {
|
||||||
|
if (showLoadingUIIfLoading) {
|
||||||
|
item {
|
||||||
|
NiaLoadingWheel(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentSize(),
|
||||||
|
contentDesc = stringResource(loadingContentDescription),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NewsFeedUiState.Success -> {
|
||||||
|
items(
|
||||||
|
feedState.feed.chunked(numberOfColumns)
|
||||||
|
) { saveableNewsResources ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = 32.dp,
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
|
) {
|
||||||
|
// The last row may not be complete, but for a consistent grid
|
||||||
|
// structure we still want an element taking up the empty space.
|
||||||
|
// Therefore, the last row may have empty boxes.
|
||||||
|
repeat(numberOfColumns) { index ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
val saveableNewsResource =
|
||||||
|
saveableNewsResources.getOrNull(index)
|
||||||
|
|
||||||
|
if (saveableNewsResource != null) {
|
||||||
|
val launchResourceIntent =
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse(saveableNewsResource.newsResource.url)
|
||||||
|
)
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
NewsResourceCardExpanded(
|
||||||
|
newsResource = saveableNewsResource.newsResource,
|
||||||
|
isBookmarked = saveableNewsResource.isSaved,
|
||||||
|
onClick = {
|
||||||
|
ContextCompat.startActivity(
|
||||||
|
context,
|
||||||
|
launchResourceIntent,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onToggleBookmark = {
|
||||||
|
onNewsResourcesCheckedChanged(
|
||||||
|
saveableNewsResource.newsResource.id,
|
||||||
|
!saveableNewsResource.isSaved
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sealed hierarchy describing the state of the feed of news resources.
|
||||||
|
*/
|
||||||
|
sealed interface NewsFeedUiState {
|
||||||
|
/**
|
||||||
|
* The feed is still loading.
|
||||||
|
*/
|
||||||
|
object Loading : NewsFeedUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed is loaded with the given list of news resources.
|
||||||
|
*/
|
||||||
|
data class Success(
|
||||||
|
/**
|
||||||
|
* The list of news resources contained in this feed.
|
||||||
|
*/
|
||||||
|
val feed: List<SaveableNewsResource>
|
||||||
|
) : NewsFeedUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun NewsFeedLoadingPreview() {
|
||||||
|
NiaTheme {
|
||||||
|
LazyColumn {
|
||||||
|
NewsFeed(
|
||||||
|
feedState = NewsFeedUiState.Loading,
|
||||||
|
showLoadingUIIfLoading = true,
|
||||||
|
loadingContentDescription = 0,
|
||||||
|
numberOfColumns = 1,
|
||||||
|
onNewsResourcesCheckedChanged = { _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun NewsFeedSingleColumnPreview() {
|
||||||
|
NiaTheme {
|
||||||
|
LazyColumn {
|
||||||
|
NewsFeed(
|
||||||
|
feedState = NewsFeedUiState.Success(
|
||||||
|
previewNewsResources.map {
|
||||||
|
SaveableNewsResource(it, false)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
showLoadingUIIfLoading = true,
|
||||||
|
loadingContentDescription = 0,
|
||||||
|
numberOfColumns = 1,
|
||||||
|
onNewsResourcesCheckedChanged = { _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(device = Devices.TABLET)
|
||||||
|
@Composable
|
||||||
|
fun NewsFeedTwoColumnPreview() {
|
||||||
|
NiaTheme {
|
||||||
|
LazyColumn {
|
||||||
|
NewsFeed(
|
||||||
|
feedState = NewsFeedUiState.Success(
|
||||||
|
(previewNewsResources + previewNewsResources).map {
|
||||||
|
SaveableNewsResource(it, false)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
showLoadingUIIfLoading = true,
|
||||||
|
loadingContentDescription = 0,
|
||||||
|
numberOfColumns = 2,
|
||||||
|
onNewsResourcesCheckedChanged = { _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
/build
|
@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("nowinandroid.android.library")
|
||||||
|
id("nowinandroid.android.feature")
|
||||||
|
id("nowinandroid.android.library.compose")
|
||||||
|
id("nowinandroid.android.library.jacoco")
|
||||||
|
id("dagger.hilt.android.plugin")
|
||||||
|
id("nowinandroid.spotless")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.compose.material3.windowSizeClass)
|
||||||
|
}
|
@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.bookmarks
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertHasClickAction
|
||||||
|
import androidx.compose.ui.test.filter
|
||||||
|
import androidx.compose.ui.test.hasAnyAncestor
|
||||||
|
import androidx.compose.ui.test.hasScrollToNodeAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onFirst
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollToNode
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI tests for [BookmarksScreen] composable.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||||
|
class BookmarksScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loading_showsLoadingSpinner() {
|
||||||
|
lateinit var windowSizeClass: WindowSizeClass
|
||||||
|
composeTestRule.setContent {
|
||||||
|
BoxWithConstraints {
|
||||||
|
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||||
|
DpSize(maxWidth, maxHeight)
|
||||||
|
)
|
||||||
|
BookmarksScreen(
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
feedState = NewsFeedUiState.Loading,
|
||||||
|
removeFromBookmarks = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithContentDescription(
|
||||||
|
composeTestRule.activity.resources.getString(R.string.saved_loading)
|
||||||
|
)
|
||||||
|
.assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun feed_whenHasBookmarks_showsBookmarks() {
|
||||||
|
lateinit var windowSizeClass: WindowSizeClass
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
BoxWithConstraints {
|
||||||
|
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||||
|
DpSize(maxWidth, maxHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
BookmarksScreen(
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
feedState = NewsFeedUiState.Success(
|
||||||
|
previewNewsResources.take(2)
|
||||||
|
.map { SaveableNewsResource(it, true) }
|
||||||
|
),
|
||||||
|
removeFromBookmarks = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(
|
||||||
|
previewNewsResources[0].title,
|
||||||
|
substring = true
|
||||||
|
)
|
||||||
|
.assertExists()
|
||||||
|
.assertHasClickAction()
|
||||||
|
|
||||||
|
composeTestRule.onNode(hasScrollToNodeAction())
|
||||||
|
.performScrollToNode(
|
||||||
|
hasText(
|
||||||
|
previewNewsResources[1].title,
|
||||||
|
substring = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(
|
||||||
|
previewNewsResources[1].title,
|
||||||
|
substring = true
|
||||||
|
)
|
||||||
|
.assertExists()
|
||||||
|
.assertHasClickAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun feed_whenRemovingBookmark_removesBookmark() {
|
||||||
|
lateinit var windowSizeClass: WindowSizeClass
|
||||||
|
|
||||||
|
var removeFromBookmarksCalled = false
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
BoxWithConstraints {
|
||||||
|
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||||
|
DpSize(maxWidth, maxHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
BookmarksScreen(
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
feedState = NewsFeedUiState.Success(
|
||||||
|
previewNewsResources.take(2)
|
||||||
|
.map { SaveableNewsResource(it, true) }
|
||||||
|
),
|
||||||
|
removeFromBookmarks = { newsResourceId ->
|
||||||
|
assertEquals(previewNewsResources[0].id, newsResourceId)
|
||||||
|
removeFromBookmarksCalled = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithContentDescription(
|
||||||
|
composeTestRule.activity.getString(
|
||||||
|
com.google.samples.apps.nowinandroid.core.ui.R.string.unbookmark
|
||||||
|
)
|
||||||
|
).filter(
|
||||||
|
hasAnyAncestor(
|
||||||
|
hasText(
|
||||||
|
previewNewsResources[0].title,
|
||||||
|
substring = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.assertCountEquals(1)
|
||||||
|
.onFirst()
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
assertTrue(removeFromBookmarksCalled)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2022 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.google.samples.apps.nowinandroid.feature.bookmarks">
|
||||||
|
|
||||||
|
</manifest>
|
@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.bookmarks
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.consumedWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NewsFeed
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BookmarksRoute(
|
||||||
|
windowSizeClass: WindowSizeClass,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: BookmarksViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val feedState by viewModel.feedState.collectAsState()
|
||||||
|
BookmarksScreen(
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
feedState = feedState,
|
||||||
|
removeFromBookmarks = viewModel::removeFromSavedResources,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BookmarksScreen(
|
||||||
|
windowSizeClass: WindowSizeClass,
|
||||||
|
feedState: NewsFeedUiState,
|
||||||
|
removeFromBookmarks: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
NiaGradientBackground {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
NiaTopAppBar(
|
||||||
|
titleRes = R.string.top_app_bar_title_saved,
|
||||||
|
navigationIcon = NiaIcons.Search,
|
||||||
|
navigationIconContentDescription = stringResource(
|
||||||
|
id = R.string.top_app_bar_action_search
|
||||||
|
),
|
||||||
|
actionIcon = NiaIcons.AccountCircle,
|
||||||
|
actionIconContentDescription = stringResource(
|
||||||
|
id = R.string.top_app_bar_action_menu
|
||||||
|
),
|
||||||
|
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier.windowInsetsPadding(
|
||||||
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
) { innerPadding ->
|
||||||
|
// TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed:
|
||||||
|
// https://issuetracker.google.com/issues/230514914
|
||||||
|
// https://issuetracker.google.com/issues/231320714
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.consumedWindowInsets(innerPadding)
|
||||||
|
) {
|
||||||
|
val numberOfColumns = when (windowSizeClass.widthSizeClass) {
|
||||||
|
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> 1
|
||||||
|
else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.testTag("saved:feed"),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
NewsFeed(
|
||||||
|
feedState = feedState,
|
||||||
|
numberOfColumns = numberOfColumns,
|
||||||
|
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
|
||||||
|
showLoadingUIIfLoading = true,
|
||||||
|
loadingContentDescription = R.string.saved_loading
|
||||||
|
)
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.bookmarks
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNot
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BookmarksViewModel @Inject constructor(
|
||||||
|
newsRepository: NewsRepository,
|
||||||
|
private val userDataRepository: UserDataRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
private val savedNewsResourcesState: StateFlow<Set<String>> =
|
||||||
|
userDataRepository.userDataStream
|
||||||
|
.map { userData ->
|
||||||
|
userData.bookmarkedNewsResources
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
val feedState: StateFlow<NewsFeedUiState> =
|
||||||
|
newsRepository
|
||||||
|
.getNewsResourcesStream()
|
||||||
|
.mapToFeedState(savedNewsResourcesState)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Flow<List<NewsResource>>.mapToFeedState(
|
||||||
|
savedNewsResourcesState: Flow<Set<String>>
|
||||||
|
): Flow<NewsFeedUiState> =
|
||||||
|
filterNot { it.isEmpty() }
|
||||||
|
.combine(savedNewsResourcesState) { newsResources, savedNewsResources ->
|
||||||
|
newsResources
|
||||||
|
.filter { newsResource -> savedNewsResources.contains(newsResource.id) }
|
||||||
|
.map { SaveableNewsResource(it, true) }
|
||||||
|
}
|
||||||
|
.map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
|
||||||
|
.onStart { emit(Loading) }
|
||||||
|
|
||||||
|
fun removeFromSavedResources(newsResourceId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
userDataRepository.updateNewsResourceBookmark(newsResourceId, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.bookmarks.navigation
|
||||||
|
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
|
||||||
|
|
||||||
|
object BookmarksDestination : NiaNavigationDestination {
|
||||||
|
override val route = "bookmarks_route"
|
||||||
|
override val destination = "bookmarks_destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavGraphBuilder.bookmarksGraph(
|
||||||
|
windowSizeClass: WindowSizeClass
|
||||||
|
) {
|
||||||
|
composable(route = BookmarksDestination.route) {
|
||||||
|
BookmarksRoute(windowSizeClass)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2022 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="saved">Saved</string>
|
||||||
|
<string name="saved_loading">Loading saved…</string>
|
||||||
|
<string name="top_app_bar_title_saved">Saved</string>
|
||||||
|
<string name="top_app_bar_action_search">Search</string>
|
||||||
|
<string name="top_app_bar_action_menu">Menu</string>
|
||||||
|
</resources>
|
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.bookmarks
|
||||||
|
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
|
||||||
|
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To learn more about how this test handles Flows created with stateIn, see
|
||||||
|
* https://developer.android.com/kotlin/flow/test#statein
|
||||||
|
*/
|
||||||
|
class BookmarksViewModelTest {
|
||||||
|
@get:Rule
|
||||||
|
val dispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val userDataRepository = TestUserDataRepository()
|
||||||
|
private val newsRepository = TestNewsRepository()
|
||||||
|
private lateinit var viewModel: BookmarksViewModel
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
viewModel = BookmarksViewModel(
|
||||||
|
userDataRepository = userDataRepository,
|
||||||
|
newsRepository = newsRepository
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateIsInitiallyLoading() = runTest {
|
||||||
|
assertEquals(Loading, viewModel.feedState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oneBookmark_showsInFeed() = runTest {
|
||||||
|
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
|
||||||
|
|
||||||
|
newsRepository.sendNewsResources(previewNewsResources)
|
||||||
|
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
|
||||||
|
val item = viewModel.feedState.value
|
||||||
|
assertTrue(item is Success)
|
||||||
|
assertEquals((item as Success).feed.size, 1)
|
||||||
|
|
||||||
|
collectJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oneBookmark_whenRemoving_removesFromFeed() = runTest {
|
||||||
|
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
|
||||||
|
// Set the news resources to be used by this test
|
||||||
|
newsRepository.sendNewsResources(previewNewsResources)
|
||||||
|
// Start with the resource saved
|
||||||
|
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
|
||||||
|
// Use viewModel to remove saved resource
|
||||||
|
viewModel.removeFromSavedResources(previewNewsResources[0].id)
|
||||||
|
// Verify list of saved resources is now empty
|
||||||
|
val item = viewModel.feedState.value
|
||||||
|
assertTrue(item is Success)
|
||||||
|
assertEquals((item as Success).feed.size, 0)
|
||||||
|
|
||||||
|
collectJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2022 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.google.samples.apps.nowinandroid.feature.foryou
|
|
||||||
|
|
||||||
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sealed hierarchy describing the state of the feed on the for you screen.
|
|
||||||
*/
|
|
||||||
sealed interface ForYouFeedUiState {
|
|
||||||
/**
|
|
||||||
* The feed is still loading.
|
|
||||||
*/
|
|
||||||
object Loading : ForYouFeedUiState
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The feed is loaded with the given list of news resources.
|
|
||||||
*/
|
|
||||||
data class Success(
|
|
||||||
/**
|
|
||||||
* The list of news resources contained in this [PopulatedFeed].
|
|
||||||
*/
|
|
||||||
val feed: List<SaveableNewsResource>
|
|
||||||
) : ForYouFeedUiState
|
|
||||||
}
|
|
Loading…
Reference in new issue