* 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