commit
73be7cebc9
@ -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 @@
|
|||||||
|
/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,22 @@
|
|||||||
|
<?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_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()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue