Acceptance criteria: https://b.corp.google.com/issues/213886281 Change-Id: I129fb3d076b831aa00c16533090cb03b2bdebdd7pull/2/head
parent
a36c0da93a
commit
d355a70292
@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.following
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import com.google.samples.apps.nowinandroid.R
|
||||||
|
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI test for checking the correct behaviour of the Following screen;
|
||||||
|
* Verifies that, when a specific UiState is set, the corresponding
|
||||||
|
* composables and details are shown
|
||||||
|
*/
|
||||||
|
class FollowingScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
private lateinit var followingLoading: String
|
||||||
|
private lateinit var followingErrorHeader: String
|
||||||
|
private lateinit var followingTopicCardIcon: String
|
||||||
|
private lateinit var followingTopicCardFollowButton: String
|
||||||
|
private lateinit var followingTopicCardUnfollowButton: String
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
composeTestRule.activity.apply {
|
||||||
|
followingLoading = getString(R.string.following_loading)
|
||||||
|
followingErrorHeader = getString(R.string.following_error_header)
|
||||||
|
followingTopicCardIcon = getString(R.string.following_topic_card_icon_content_desc)
|
||||||
|
followingTopicCardFollowButton =
|
||||||
|
getString(R.string.following_topic_card_follow_button_content_desc)
|
||||||
|
followingTopicCardUnfollowButton =
|
||||||
|
getString(R.string.following_topic_card_unfollow_button_content_desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun niaLoadingIndicator_whenScreenIsLoading_showLoading() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
FollowingScreen(
|
||||||
|
uiState = FollowingUiState.Loading,
|
||||||
|
followTopic = { _, _ -> },
|
||||||
|
navigateToTopic = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithContentDescription(followingLoading)
|
||||||
|
.assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun followingWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
FollowingScreen(
|
||||||
|
uiState = FollowingUiState.Topics(topics = testTopics),
|
||||||
|
followTopic = { _, _ -> },
|
||||||
|
navigateToTopic = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(TOPIC_1_NAME)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(TOPIC_2_NAME)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(TOPIC_3_NAME)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithText(TOPIC_DESC)
|
||||||
|
.assertCountEquals(testTopics.count())
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithContentDescription(followingTopicCardIcon)
|
||||||
|
.assertCountEquals(testTopics.count())
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithContentDescription(followingTopicCardFollowButton)
|
||||||
|
.assertCountEquals(numberOfUnfollowedTopics)
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithContentDescription(followingTopicCardUnfollowButton)
|
||||||
|
.assertCountEquals(testTopics.filter { it.followed }.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun followingError_whenErrorOccurs_thenShowEmptyErrorScreen() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
FollowingScreen(
|
||||||
|
uiState = FollowingUiState.Error,
|
||||||
|
followTopic = { _, _ -> },
|
||||||
|
navigateToTopic = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(followingErrorHeader)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TOPIC_1_NAME = "Headlines"
|
||||||
|
private const val TOPIC_2_NAME = "UI"
|
||||||
|
private const val TOPIC_3_NAME = "Tools"
|
||||||
|
private const val TOPIC_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
|
||||||
|
|
||||||
|
private val testTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = TOPIC_1_NAME,
|
||||||
|
description = TOPIC_DESC,
|
||||||
|
followed = true
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = TOPIC_2_NAME,
|
||||||
|
description = TOPIC_DESC
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = TOPIC_3_NAME,
|
||||||
|
description = TOPIC_DESC
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val numberOfUnfollowedTopics = testTopics.filter { !it.followed }.size
|
@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 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.ui
|
||||||
|
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.ripple.RippleAlpha
|
||||||
|
import androidx.compose.material.ripple.RippleTheme
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NiaToolbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
@StringRes titleRes: Int,
|
||||||
|
onSearchClick: () -> Unit = {},
|
||||||
|
onMenuClick: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { onSearchClick() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Search,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = titleRes),
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
IconButton(onClick = { onMenuClick() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.MoreVert,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NiaLoadingIndicator(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentDesc: String
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.wrapContentSize(Alignment.Center)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.semantics { contentDescription = contentDesc },
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object ClearRippleTheme : RippleTheme {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun defaultColor(): Color = Color.Transparent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun rippleAlpha() = RippleAlpha(
|
||||||
|
draggedAlpha = 0.0f,
|
||||||
|
focusedAlpha = 0.0f,
|
||||||
|
hoveredAlpha = 0.0f,
|
||||||
|
pressedAlpha = 0.0f,
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,258 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 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.ui.following
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconToggleButton
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Android
|
||||||
|
import androidx.compose.material.icons.filled.Done
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.google.samples.apps.nowinandroid.R
|
||||||
|
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.ui.NiaLoadingIndicator
|
||||||
|
import com.google.samples.apps.nowinandroid.ui.NiaToolbar
|
||||||
|
import com.google.samples.apps.nowinandroid.ui.theme.NiaTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FollowingRoute(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
navigateToTopic: () -> Unit,
|
||||||
|
viewModel: FollowingViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
FollowingScreen(
|
||||||
|
modifier = modifier,
|
||||||
|
uiState = uiState,
|
||||||
|
followTopic = viewModel::followTopic,
|
||||||
|
navigateToTopic = navigateToTopic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FollowingScreen(
|
||||||
|
uiState: FollowingUiState,
|
||||||
|
followTopic: (Int, Boolean) -> Unit,
|
||||||
|
navigateToTopic: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
NiaToolbar(titleRes = R.string.following)
|
||||||
|
when (uiState) {
|
||||||
|
FollowingUiState.Loading ->
|
||||||
|
NiaLoadingIndicator(
|
||||||
|
modifier = modifier,
|
||||||
|
contentDesc = stringResource(id = R.string.following_loading),
|
||||||
|
)
|
||||||
|
is FollowingUiState.Topics ->
|
||||||
|
FollowingWithTopicsScreen(
|
||||||
|
uiState = uiState,
|
||||||
|
onTopicClick = { navigateToTopic() },
|
||||||
|
onFollowButtonClick = followTopic,
|
||||||
|
)
|
||||||
|
is FollowingUiState.Error -> FollowingErrorScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FollowingWithTopicsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
uiState: FollowingUiState.Topics,
|
||||||
|
onTopicClick: () -> Unit,
|
||||||
|
onFollowButtonClick: (Int, Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
uiState.topics.forEach {
|
||||||
|
item {
|
||||||
|
FollowingTopicCard(
|
||||||
|
topic = it,
|
||||||
|
onTopicClick = onTopicClick,
|
||||||
|
onFollowButtonClick = onFollowButtonClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FollowingErrorScreen() {
|
||||||
|
Text(text = stringResource(id = R.string.following_error_header))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FollowingTopicCard(
|
||||||
|
topic: Topic,
|
||||||
|
onTopicClick: () -> Unit,
|
||||||
|
onFollowButtonClick: (Int, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(
|
||||||
|
start = 24.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
bottom = 24.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
TopicIcon(
|
||||||
|
modifier = Modifier.padding(end = 24.dp),
|
||||||
|
onClick = onTopicClick
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.wrapContentSize(Alignment.Center)
|
||||||
|
.weight(1f)
|
||||||
|
.clickable { onTopicClick() }
|
||||||
|
) {
|
||||||
|
TopicTitle(topicName = topic.name)
|
||||||
|
TopicDescription(topicDescription = topic.description)
|
||||||
|
}
|
||||||
|
FollowButton(
|
||||||
|
topicId = topic.id,
|
||||||
|
onClick = onFollowButtonClick,
|
||||||
|
isFollowed = topic.followed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopicTitle(
|
||||||
|
topicName: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = topicName,
|
||||||
|
style = MaterialTheme.typography.h5,
|
||||||
|
modifier = modifier.padding(top = 12.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopicDescription(topicDescription: String) {
|
||||||
|
Text(
|
||||||
|
text = topicDescription,
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
modifier = Modifier.wrapContentSize(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopicIcon(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Android,
|
||||||
|
tint = Color.Magenta,
|
||||||
|
contentDescription = stringResource(id = R.string.following_topic_card_icon_content_desc),
|
||||||
|
modifier = modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clickable { onClick() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FollowButton(
|
||||||
|
topicId: Int,
|
||||||
|
isFollowed: Boolean,
|
||||||
|
onClick: (Int, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
IconToggleButton(
|
||||||
|
checked = isFollowed,
|
||||||
|
onCheckedChange = { onClick(topicId, !isFollowed) }
|
||||||
|
) {
|
||||||
|
if (isFollowed) {
|
||||||
|
FollowedTopicIcon()
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription =
|
||||||
|
stringResource(id = R.string.following_topic_card_follow_button_content_desc),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FollowedTopicIcon() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(30.dp)
|
||||||
|
.background(
|
||||||
|
color = Color.Magenta.copy(alpha = 0.5f),
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Done,
|
||||||
|
contentDescription =
|
||||||
|
stringResource(id = R.string.following_topic_card_unfollow_button_content_desc),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview("Topic card")
|
||||||
|
@Composable
|
||||||
|
fun TopicCardPreview() {
|
||||||
|
NiaTheme {
|
||||||
|
Surface {
|
||||||
|
FollowingTopicCard(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Compose",
|
||||||
|
description = "Description"
|
||||||
|
),
|
||||||
|
onTopicClick = {},
|
||||||
|
onFollowButtonClick = { _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 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.ui.following
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.data.repository.TopicsRepository
|
||||||
|
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.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FollowingViewModel @Inject constructor(
|
||||||
|
private val topicsRepository: TopicsRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val followedTopicIdsStream = topicsRepository.getFollowedTopicIdsStream()
|
||||||
|
.catch { FollowingState.Error }
|
||||||
|
.map { followedTopics ->
|
||||||
|
FollowingState.Topics(topics = followedTopics)
|
||||||
|
}
|
||||||
|
|
||||||
|
val uiState: StateFlow<FollowingUiState> = combine(
|
||||||
|
followedTopicIdsStream,
|
||||||
|
topicsRepository.getTopicsStream(),
|
||||||
|
) { followedTopicIdsState, topics ->
|
||||||
|
if (followedTopicIdsState is FollowingState.Topics) {
|
||||||
|
mapFollowedAndUnfollowedTopics(topics)
|
||||||
|
} else {
|
||||||
|
flowOf(FollowingUiState.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flatMapLatest { it }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = FollowingUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
fun followTopic(followedTopicId: Int, followed: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
topicsRepository.toggleFollowedTopicId(followedTopicId, followed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapFollowedAndUnfollowedTopics(topics: List<Topic>): Flow<FollowingUiState.Topics> =
|
||||||
|
topicsRepository.getFollowedTopicIdsStream().map { followedTopicIds ->
|
||||||
|
FollowingUiState.Topics(
|
||||||
|
topics =
|
||||||
|
topics.map {
|
||||||
|
Topic(
|
||||||
|
it.id,
|
||||||
|
it.name,
|
||||||
|
it.description,
|
||||||
|
followedTopicIds.contains(it.id)
|
||||||
|
)
|
||||||
|
}.sortedBy { it.name }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface FollowingState {
|
||||||
|
data class Topics(val topics: Set<Int>) : FollowingState
|
||||||
|
object Error : FollowingState
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface FollowingUiState {
|
||||||
|
object Loading : FollowingUiState
|
||||||
|
data class Topics(val topics: List<Topic>) : FollowingUiState
|
||||||
|
object Error : FollowingUiState
|
||||||
|
}
|
@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.following
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.testutil.TestDispatcherRule
|
||||||
|
import com.google.samples.apps.nowinandroid.testutil.TestTopicsRepository
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FollowingViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val dispatcherRule = TestDispatcherRule()
|
||||||
|
|
||||||
|
private val topicsRepository = TestTopicsRepository()
|
||||||
|
private lateinit var viewModel: FollowingViewModel
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
viewModel = FollowingViewModel(topicsRepository = topicsRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_whenInitialized_thenShowLoading() = runTest {
|
||||||
|
viewModel.uiState.test {
|
||||||
|
assertEquals(FollowingUiState.Loading, awaitItem())
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
|
||||||
|
viewModel.uiState.test {
|
||||||
|
assertEquals(FollowingUiState.Loading, awaitItem())
|
||||||
|
topicsRepository.setFollowedTopicIds(emptySet())
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
|
||||||
|
viewModel.uiState
|
||||||
|
.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(testInputTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[0].id))
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
viewModel.followTopic(
|
||||||
|
followedTopicId = testInputTopics[1].id,
|
||||||
|
followed = !testInputTopics[1].followed
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
FollowingUiState.Topics(topics = testOutputTopics),
|
||||||
|
awaitItem()
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
|
||||||
|
viewModel.uiState
|
||||||
|
.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(testOutputTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(
|
||||||
|
setOf(testOutputTopics[0].id, testOutputTopics[1].id)
|
||||||
|
)
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
viewModel.followTopic(
|
||||||
|
followedTopicId = testOutputTopics[1].id,
|
||||||
|
followed = !testOutputTopics[1].followed
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
FollowingUiState.Topics(topics = testInputTopics),
|
||||||
|
awaitItem()
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TOPIC_1_NAME = "Android Studio"
|
||||||
|
private const val TOPIC_2_NAME = "Build"
|
||||||
|
private const val TOPIC_3_NAME = "Compose"
|
||||||
|
private const val TOPIC_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
|
||||||
|
|
||||||
|
private val testInputTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = TOPIC_1_NAME,
|
||||||
|
description = TOPIC_DESC,
|
||||||
|
followed = true
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = TOPIC_2_NAME,
|
||||||
|
description = TOPIC_DESC
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = TOPIC_3_NAME,
|
||||||
|
description = TOPIC_DESC
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val testOutputTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = TOPIC_1_NAME,
|
||||||
|
description = TOPIC_DESC,
|
||||||
|
followed = true
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = TOPIC_2_NAME,
|
||||||
|
description = TOPIC_DESC,
|
||||||
|
followed = true
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = TOPIC_3_NAME,
|
||||||
|
description = TOPIC_DESC
|
||||||
|
)
|
||||||
|
)
|
Loading…
Reference in new issue