Add People to interests screen

Screenshot: https://screenshot.googleplex.com/3XqkTwJQwgfoPsT

Change-Id: Iac7dc06c84523b4db8d05f538b74a08741d2c347
pull/2/head
Manuel Vivo 3 years ago committed by Don Turner
parent de2f07d1a4
commit 3558a6931e

@ -68,7 +68,7 @@ class NavigationTest {
private lateinit var forYou: String
private lateinit var episodes: String
private lateinit var saved: String
private lateinit var topics: String
private lateinit var interests: String
private lateinit var sampleTopic: String
@Before
@ -80,7 +80,7 @@ class NavigationTest {
forYou = getString(R.string.for_you)
episodes = getString(R.string.episodes)
saved = getString(R.string.saved)
topics = getString(R.string.following)
interests = getString(R.string.interests)
sampleTopic = "Headlines"
}
}
@ -105,8 +105,8 @@ class NavigationTest {
composeTestRule.apply {
// GIVEN the user follows a topic
onNodeWithText(sampleTopic).performClick()
// WHEN the user navigates to the Topics destination
onNodeWithText(topics).performClick()
// WHEN the user navigates to the Interests destination
onNodeWithText(interests).performClick()
// AND the user navigates to the For You destination
onNodeWithText(forYou).performClick()
// THEN the state of the For You destination is restored
@ -146,7 +146,7 @@ class NavigationTest {
// GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown.
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
// TODO: Add top level destinations here, see b/226357686.
onNodeWithText(topics).performClick()
onNodeWithText(interests).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
}
}
@ -157,8 +157,8 @@ class NavigationTest {
@Test(expected = NoActivityResumedException::class)
fun homeDestination_back_quitsApp() {
composeTestRule.apply {
// GIVEN the user navigates to the Topics destination
onNodeWithText(topics).performClick()
// GIVEN the user navigates to the Interests destination
onNodeWithText(interests).performClick()
// and then navigates to the For you destination
onNodeWithText(forYou).performClick()
// WHEN the user uses the system button/gesture to go back
@ -174,8 +174,8 @@ class NavigationTest {
@Test
fun navigationBar_backFromAnyDestination_returnsToForYou() {
composeTestRule.apply {
// GIVEN the user navigated to the Topics destination
onNodeWithText(topics).performClick()
// GIVEN the user navigated to the Interests destination
onNodeWithText(interests).performClick()
// TODO: Add another destination here to increase test coverage, see b/226357686.
// WHEN the user uses the system button/gesture to go back,
Espresso.pressBack()
@ -187,16 +187,16 @@ class NavigationTest {
@Test
fun navigationBar_multipleBackStackFollowing() {
composeTestRule.apply {
onNodeWithText(topics).performClick()
onNodeWithText(interests).performClick()
onNodeWithText("Android Studio").performClick() // TODO: Grab string from fake data
// Switch tab
onNodeWithText(forYou).performClick()
// Come back to Following
onNodeWithText(topics).performClick()
onNodeWithText(interests).performClick()
// Verify we're not in the list of topics
// Verify we're not in the list of interests
onNodeWithText("Android Auto").assertDoesNotExist() // TODO: Grab string from fake data
}
}

@ -199,7 +199,7 @@ private sealed class Destination(
route = NiaDestinations.FOLLOWING_ROUTE,
selectedIcon = Icons.Filled.Grid3x3,
unselectedIcon = Icons.Outlined.Grid3x3,
iconTextId = R.string.following
iconTextId = R.string.interests
)
}

@ -26,12 +26,12 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.following.FollowingRoute
import com.google.samples.apps.nowinandroid.feature.following.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinations
import com.google.samples.apps.nowinandroid.feature.topic.InterestsDestinations
import com.google.samples.apps.nowinandroid.feature.topic.InterestsScreens.TOPIC_SCREEN
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinationsArgs
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreens.TOPIC_SCREEN
/**
* Top-level navigation graph. Navigation is organized as explained at
@ -60,17 +60,18 @@ fun NiaNavGraph(
Text("SAVED", modifier)
}
navigation(
startDestination = TopicDestinations.TOPICS_ROUTE,
startDestination = InterestsDestinations.INTERESTS_ROUTE,
route = NiaDestinations.FOLLOWING_ROUTE
) {
composable(TopicDestinations.TOPICS_ROUTE) {
FollowingRoute(
composable(InterestsDestinations.INTERESTS_ROUTE) {
InterestsRoute(
navigateToTopic = { navController.navigate("$TOPIC_SCREEN/$it") },
navigateToAuthor = { /* TO IMPLEMENT */ },
modifier = modifier
)
}
composable(
TopicDestinations.TOPIC_ROUTE,
InterestsDestinations.TOPIC_ROUTE,
arguments = listOf(
navArgument(TopicDestinationsArgs.TOPIC_ID_ARG) {
type = NavType.IntType

@ -22,7 +22,6 @@ 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
@ -42,7 +41,6 @@ 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(
@ -54,9 +52,7 @@ fun NiaToolbar(
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
modifier = modifier.fillMaxWidth()
) {
IconButton(onClick = { onSearchClick() }) {
Icon(

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.following
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -24,9 +25,12 @@ 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.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.feature.following.FollowingScreen
import com.google.samples.apps.nowinandroid.feature.following.FollowingTabState
import com.google.samples.apps.nowinandroid.feature.following.FollowingUiState
import com.google.samples.apps.nowinandroid.feature.following.R
import org.junit.Before
@ -34,18 +38,17 @@ import org.junit.Rule
import org.junit.Test
/**
* UI test for checking the correct behaviour of the Following screen;
* UI test for checking the correct behaviour of the Interests screen;
* Verifies that, when a specific UiState is set, the corresponding
* composables and details are shown
*/
class FollowingScreenTest {
class InterestsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var followingLoading: String
private lateinit var followingErrorHeader: String
private lateinit var followingTopicCardIcon: String
private lateinit var followingEmptyHeader: String
private lateinit var followingTopicCardFollowButton: String
private lateinit var followingTopicCardUnfollowButton: String
@ -53,23 +56,29 @@ class FollowingScreenTest {
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)
followingEmptyHeader = getString(R.string.following_empty_header)
followingTopicCardFollowButton =
getString(R.string.following_topic_card_follow_button_content_desc)
getString(R.string.interests_card_follow_button_content_desc)
followingTopicCardUnfollowButton =
getString(R.string.following_topic_card_unfollow_button_content_desc)
getString(R.string.interests_card_unfollow_button_content_desc)
}
}
@Test
fun niaLoadingIndicator_whenScreenIsLoading_showLoading() {
fun niaLoadingIndicator_inTopics_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
FollowingScreen(
uiState = FollowingUiState.Loading,
followTopic = { _, _ -> },
navigateToTopic = {}
)
InterestsScreen(uiState = FollowingUiState.Loading, tabIndex = 0)
}
composeTestRule
.onNodeWithContentDescription(followingLoading)
.assertExists()
}
@Test
fun niaLoadingIndicator_inAuthors_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
InterestsScreen(uiState = FollowingUiState.Loading, tabIndex = 1)
}
composeTestRule
@ -78,12 +87,11 @@ class FollowingScreenTest {
}
@Test
fun followingWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
FollowingScreen(
uiState = FollowingUiState.Topics(topics = testTopics),
followTopic = { _, _ -> },
navigateToTopic = {}
InterestsScreen(
uiState = FollowingUiState.Interests(topics = testTopics, authors = listOf()),
tabIndex = 0
)
}
@ -102,12 +110,36 @@ class FollowingScreenTest {
.assertCountEquals(testTopics.count())
composeTestRule
.onAllNodesWithContentDescription(followingTopicCardIcon)
.assertCountEquals(testTopics.count())
.onAllNodesWithContentDescription(followingTopicCardFollowButton)
.assertCountEquals(numberOfUnfollowedTopics)
composeTestRule
.onAllNodesWithContentDescription(followingTopicCardUnfollowButton)
.assertCountEquals(testAuthors.filter { it.isFollowed }.size)
}
@Test
fun interestsWithTopics_whenAuthorsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = FollowingUiState.Interests(topics = listOf(), authors = testAuthors),
tabIndex = 1
)
}
composeTestRule
.onNodeWithText("Android Dev")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Android Dev 2")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Android Dev 3")
.assertIsDisplayed()
composeTestRule
.onAllNodesWithContentDescription(followingTopicCardFollowButton)
.assertCountEquals(numberOfUnfollowedTopics)
.assertCountEquals(numberOfUnfollowedAuthors)
composeTestRule
.onAllNodesWithContentDescription(followingTopicCardUnfollowButton)
@ -115,19 +147,42 @@ class FollowingScreenTest {
}
@Test
fun followingError_whenErrorOccurs_thenShowEmptyErrorScreen() {
fun topicsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent {
FollowingScreen(
uiState = FollowingUiState.Error,
followTopic = { _, _ -> },
navigateToTopic = {}
)
InterestsScreen(uiState = FollowingUiState.Empty, tabIndex = 0)
}
composeTestRule
.onNodeWithText(followingEmptyHeader)
.assertIsDisplayed()
}
@Test
fun authorsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent {
InterestsScreen(uiState = FollowingUiState.Empty, tabIndex = 1)
}
composeTestRule
.onNodeWithText(followingErrorHeader)
.onNodeWithText(followingEmptyHeader)
.assertIsDisplayed()
}
@Composable
private fun InterestsScreen(uiState: FollowingUiState, tabIndex: Int = 0) {
FollowingScreen(
uiState = uiState,
tabState = FollowingTabState(
titles = listOf(R.string.following_topics, R.string.following_people),
currentIndex = tabIndex
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {},
)
}
}
private const val TOPIC_1_NAME = "Headlines"
@ -174,4 +229,38 @@ private val testTopics = listOf(
)
)
private val testAuthors = listOf(
FollowableAuthor(
Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = true
),
FollowableAuthor(
Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
)
private val numberOfUnfollowedTopics = testTopics.filter { !it.isFollowed }.size
private val numberOfUnfollowedAuthors = testAuthors.filter { !it.isFollowed }.size

@ -0,0 +1,189 @@
/*
* 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.following
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.Android
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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.ui.FollowButton
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.feature.following.R.string
@Composable
fun FollowingItem(
name: String,
following: Boolean,
topicImageUrl: String,
onClick: () -> Unit,
onFollowButtonClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
description: String = "",
itemSeparation: Dp = 16.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.clickable { onClick() }
.padding(vertical = itemSeparation)
) {
FollowingIcon(topicImageUrl, iconModifier.size(64.dp))
Spacer(modifier = Modifier.width(16.dp))
InterestContent(name, description)
}
FollowButton(
following = following,
onFollowChange = onFollowButtonClick,
notFollowingContentDescription = stringResource(
id = string.interests_card_follow_button_content_desc
),
followingContentDescription = stringResource(
id = string.interests_card_unfollow_button_content_desc
)
)
}
}
@Composable
private fun InterestContent(name: String, description: String, modifier: Modifier = Modifier) {
Column(modifier) {
Text(
text = name,
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(
vertical = if (description.isEmpty()) 0.dp else 4.dp
)
)
if (description.isNotEmpty()) {
Text(
text = description,
style = MaterialTheme.typography.body2
)
}
}
}
@Composable
private fun FollowingIcon(topicImageUrl: String, modifier: Modifier = Modifier) {
if (topicImageUrl.isEmpty()) {
Icon(
imageVector = Filled.Android,
tint = Color.Magenta,
contentDescription = null,
modifier = modifier
)
} else {
AsyncImage(
model = topicImageUrl,
contentDescription = null,
modifier = modifier
)
}
}
@Preview
@Composable
private fun FollowingCardPreview() {
NiaTheme {
Surface {
FollowingItem(
name = "Compose",
description = "Description",
following = false,
topicImageUrl = "",
onClick = { },
onFollowButtonClick = { }
)
}
}
}
@Preview
@Composable
private fun FollowingCardLongNamePreview() {
NiaTheme {
Surface {
FollowingItem(
name = "This is a very very very very long name",
description = "Description",
following = true,
topicImageUrl = "",
onClick = { },
onFollowButtonClick = { }
)
}
}
}
@Preview
@Composable
private fun FollowingCardLongDescriptionPreview() {
NiaTheme {
Surface {
FollowingItem(
name = "Compose",
description = "This is a very very very very very very very " +
"very very very long description",
following = false,
topicImageUrl = "",
onClick = { },
onFollowButtonClick = { }
)
}
}
}
@Preview
@Composable
private fun FollowingCardWithEmptyDescriptionPreview() {
NiaTheme {
Surface {
FollowingItem(
name = "Compose",
description = "",
following = true,
topicImageUrl = "",
onClick = { },
onFollowButtonClick = { }
)
}
}
}

@ -16,221 +16,119 @@
package com.google.samples.apps.nowinandroid.feature.following
import androidx.compose.foundation.clickable
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.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
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 coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.ui.FollowButton
import com.google.samples.apps.nowinandroid.core.ui.NiaLoadingIndicator
import com.google.samples.apps.nowinandroid.core.ui.NiaToolbar
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTab
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTabRow
@Composable
fun FollowingRoute(
fun InterestsRoute(
modifier: Modifier = Modifier,
navigateToAuthor: () -> Unit,
navigateToTopic: (Int) -> Unit,
viewModel: FollowingViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val tabState by viewModel.tabState.collectAsState()
FollowingScreen(
modifier = modifier,
uiState = uiState,
tabState = tabState,
followTopic = viewModel::followTopic,
navigateToTopic = navigateToTopic
followAuthor = viewModel::followAuthor,
navigateToAuthor = navigateToAuthor,
navigateToTopic = navigateToTopic,
switchTab = viewModel::switchTab,
modifier = modifier
)
}
@Composable
fun FollowingScreen(
uiState: FollowingUiState,
tabState: FollowingTabState,
followAuthor: (Int, Boolean) -> Unit,
followTopic: (Int, Boolean) -> Unit,
navigateToAuthor: () -> Unit,
navigateToTopic: (Int) -> Unit,
switchTab: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
NiaToolbar(titleRes = R.string.following)
NiaToolbar(titleRes = R.string.interests)
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.Interests ->
FollowingContent(
tabState, switchTab, uiState, navigateToTopic, followTopic,
navigateToAuthor, followAuthor
)
is FollowingUiState.Error -> FollowingErrorScreen()
is FollowingUiState.Empty -> InterestsEmptyScreen()
}
}
}
@Composable
fun FollowingWithTopicsScreen(
modifier: Modifier = Modifier,
uiState: FollowingUiState.Topics,
onTopicClick: (Int) -> Unit,
onFollowButtonClick: (Int, Boolean) -> Unit
private fun FollowingContent(
tabState: FollowingTabState,
switchTab: (Int) -> Unit,
uiState: FollowingUiState.Interests,
navigateToTopic: (Int) -> Unit,
followTopic: (Int, Boolean) -> Unit,
navigateToAuthor: () -> Unit,
followAuthor: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier
) {
uiState.topics.forEach { followableTopic ->
item {
FollowingTopicCard(
followableTopic = followableTopic,
onTopicClick = { onTopicClick(followableTopic.topic.id) },
onFollowButtonClick = onFollowButtonClick
Column(modifier) {
NiaTabRow(selectedTabIndex = tabState.currentIndex) {
tabState.titles.forEachIndexed { index, titleId ->
NiaTab(
selected = index == tabState.currentIndex,
onClick = { switchTab(index) },
text = { Text(text = stringResource(id = titleId)) }
)
}
}
}
}
@Composable
fun FollowingErrorScreen() {
Text(text = stringResource(id = R.string.following_error_header))
}
@Composable
fun FollowingTopicCard(
followableTopic: FollowableTopic,
onTopicClick: () -> Unit,
onFollowButtonClick: (Int, Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.padding(
start = 24.dp,
end = 8.dp,
bottom = 24.dp
)
) {
TopicIcon(
modifier = Modifier.padding(end = 24.dp),
topicImageUrl = followableTopic.topic.imageUrl,
onClick = onTopicClick
)
Column(
Modifier
.wrapContentSize(Alignment.CenterStart)
.weight(1f)
.clickable { onTopicClick() }
) {
TopicTitle(topicName = followableTopic.topic.name)
TopicDescription(topicDescription = followableTopic.topic.shortDescription)
when (tabState.currentIndex) {
0 -> {
TopicsTabContent(
topics = uiState.topics,
onTopicClick = navigateToTopic,
onFollowButtonClick = followTopic,
modifier = Modifier.padding(top = 8.dp)
)
}
1 -> {
AuthorsTabContent(
authors = uiState.authors,
onAuthorClick = { navigateToAuthor() },
onFollowButtonClick = followAuthor,
modifier = Modifier.padding(top = 8.dp)
)
}
}
FollowButton(
following = followableTopic.isFollowed,
onFollowChange = { following ->
onFollowButtonClick(followableTopic.topic.id, following)
},
notFollowingContentDescription = stringResource(
id = R.string.following_topic_card_follow_button_content_desc
),
followingContentDescription = stringResource(
id = R.string.following_topic_card_unfollow_button_content_desc
)
)
}
}
@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.CenterStart)
)
}
@Composable
fun TopicIcon(
modifier: Modifier = Modifier,
topicImageUrl: String,
onClick: () -> Unit
) {
val iconModifier = modifier.size(64.dp)
.clickable { onClick() }
val contentDescription = stringResource(id = R.string.following_topic_card_icon_content_desc)
if (topicImageUrl.isEmpty()) {
Icon(
imageVector = Icons.Filled.Android,
tint = Color.Magenta,
contentDescription = contentDescription,
modifier = iconModifier
)
} else {
AsyncImage(
model = topicImageUrl,
contentDescription = contentDescription,
modifier = iconModifier
)
}
}
@Preview("Topic card")
@Composable
fun TopicCardPreview() {
NiaTheme {
Surface {
FollowingTopicCard(
FollowableTopic(
Topic(
id = 0,
name = "Compose",
shortDescription = "Short description",
longDescription = "Long description",
url = "URL",
imageUrl = "imageUrl"
),
isFollowed = false
),
onTopicClick = {},
onFollowButtonClick = { _, _ -> }
)
}
}
private fun InterestsEmptyScreen() {
Text(text = stringResource(id = R.string.following_empty_header))
}

@ -18,44 +18,61 @@ package com.google.samples.apps.nowinandroid.feature.following
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.asStateFlow
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.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class FollowingViewModel @Inject constructor(
private val authorsRepository: AuthorsRepository,
private val topicsRepository: TopicsRepository
) : ViewModel() {
private val followedTopicIdsStream = topicsRepository.getFollowedTopicIdsStream()
.map<Set<Int>, FollowingState> { followedTopics ->
FollowingState.Topics(topics = followedTopics)
}
.catch { emit(FollowingState.Error) }
private val _tabState = MutableStateFlow(
FollowingTabState(
titles = listOf(R.string.following_topics, R.string.following_people),
currentIndex = 0
)
)
val tabState: StateFlow<FollowingTabState> = _tabState.asStateFlow()
val uiState: StateFlow<FollowingUiState> = combine(
followedTopicIdsStream,
authorsRepository.getAuthorsStream(),
authorsRepository.getFollowedAuthorIdsStream(),
topicsRepository.getTopicsStream(),
) { followedTopicIdsState, topics ->
if (followedTopicIdsState is FollowingState.Topics) {
mapFollowedAndUnfollowedTopics(topics)
} else {
flowOf(FollowingUiState.Error)
}
topicsRepository.getFollowedTopicIdsStream(),
) { availableAuthors, followedAuthorIdsState, availableTopics, followedTopicIdsState ->
FollowingUiState.Interests(
authors = availableAuthors
.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in followedAuthorIdsState
)
}
.sortedBy { it.author.name },
topics = availableTopics
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in followedTopicIdsState
)
}
.sortedBy { it.topic.name }
)
}
.flatMapLatest { it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
@ -68,28 +85,33 @@ class FollowingViewModel @Inject constructor(
}
}
private fun mapFollowedAndUnfollowedTopics(topics: List<Topic>): Flow<FollowingUiState.Topics> =
topicsRepository.getFollowedTopicIdsStream().map { followedTopicIds ->
FollowingUiState.Topics(
topics = topics
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in followedTopicIds,
)
}
.sortedBy { it.topic.name }
)
fun followAuthor(followedAuthorId: Int, followed: Boolean) {
viewModelScope.launch {
authorsRepository.toggleFollowedAuthorId(followedAuthorId, followed)
}
}
}
private sealed interface FollowingState {
data class Topics(val topics: Set<Int>) : FollowingState
object Error : FollowingState
fun switchTab(newIndex: Int) {
if (newIndex != tabState.value.currentIndex) {
_tabState.update {
it.copy(currentIndex = newIndex)
}
}
}
}
data class FollowingTabState(
val titles: List<Int>,
val currentIndex: Int
)
sealed interface FollowingUiState {
object Loading : FollowingUiState
data class Topics(val topics: List<FollowableTopic>) : FollowingUiState
object Error : FollowingUiState
data class Interests(
val authors: List<FollowableAuthor>,
val topics: List<FollowableTopic>
) : FollowingUiState
object Empty : FollowingUiState
}

@ -0,0 +1,77 @@
/*
* 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.following
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@Composable
fun TopicsTabContent(
topics: List<FollowableTopic>,
onTopicClick: (Int) -> Unit,
onFollowButtonClick: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp)
) {
topics.forEach { followableTopic ->
item {
FollowingItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(followableTopic.topic.id) },
onFollowButtonClick = { onFollowButtonClick(followableTopic.topic.id, it) }
)
}
}
}
}
@Composable
fun AuthorsTabContent(
authors: List<FollowableAuthor>,
onAuthorClick: () -> Unit,
onFollowButtonClick: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp)
) {
authors.forEach { followableTopic ->
item {
FollowingItem(
name = followableTopic.author.name,
following = followableTopic.isFollowed,
topicImageUrl = followableTopic.author.imageUrl,
onClick = onAuthorClick,
onFollowButtonClick = { onFollowButtonClick(followableTopic.author.id, it) },
iconModifier = Modifier.clip(CircleShape)
)
}
}
}
}

@ -14,10 +14,11 @@
~ limitations under the License.
-->
<resources>
<string name="following">Following</string>
<string name="following_loading">Loading topics</string>
<string name="following_error_header">"Error loading topics"</string>
<string name="following_topic_card_icon_content_desc">Topic icon</string>
<string name="following_topic_card_follow_button_content_desc">Follow Topic button</string>
<string name="following_topic_card_unfollow_button_content_desc">Unfollow Topic button</string>
<string name="interests">Interests</string>
<string name="following_topics">Topics</string>
<string name="following_people">People</string>
<string name="following_loading">Loading data</string>
<string name="following_empty_header">"No available data"</string>
<string name="interests_card_follow_button_content_desc">Follow interest button</string>
<string name="interests_card_unfollow_button_content_desc">Unfollow interest button</string>
</resources>

@ -17,8 +17,11 @@
package com.google.samples.apps.nowinandroid.following
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule
import com.google.samples.apps.nowinandroid.feature.following.FollowingUiState
@ -34,12 +37,13 @@ class FollowingViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository()
private lateinit var viewModel: FollowingViewModel
@Before
fun setup() {
viewModel = FollowingViewModel(topicsRepository = topicsRepository)
viewModel = FollowingViewModel(authorsRepository, topicsRepository)
}
@Test
@ -54,24 +58,36 @@ class FollowingViewModelTest {
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(FollowingUiState.Loading, awaitItem())
authorsRepository.setFollowedAuthorIds(setOf(1))
topicsRepository.setFollowedTopicIds(emptySet())
cancel()
}
}
@Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
fun uiState_whenFollowedAuthorsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(FollowingUiState.Loading, awaitItem())
authorsRepository.setFollowedAuthorIds(emptySet())
topicsRepository.setFollowedTopicIds(setOf(1))
cancel()
}
}
@Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(emptyList())
authorsRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
assertEquals(
false,
(awaitItem() as FollowingUiState.Topics)
(awaitItem() as FollowingUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
@ -81,9 +97,32 @@ class FollowingViewModelTest {
)
assertEquals(
true,
(awaitItem() as FollowingUiState.Topics)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
FollowingUiState.Interests(topics = testOutputTopics, authors = emptyList()),
awaitItem()
)
cancel()
}
}
@Test
fun uiState_whenFollowingNewAuthor_thenShowUpdatedAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[0].author.id))
topicsRepository.sendTopics(listOf())
topicsRepository.setFollowedTopicIds(setOf())
awaitItem()
viewModel.followAuthor(
followedAuthorId = testInputAuthors[1].author.id,
followed = true
)
assertEquals(
FollowingUiState.Interests(topics = emptyList(), authors = testOutputAuthors),
awaitItem()
)
cancel()
}
@ -95,6 +134,8 @@ class FollowingViewModelTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(emptyList())
authorsRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testOutputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
@ -102,7 +143,7 @@ class FollowingViewModelTest {
assertEquals(
true,
(awaitItem() as FollowingUiState.Topics)
(awaitItem() as FollowingUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
@ -112,9 +153,34 @@ class FollowingViewModelTest {
)
assertEquals(
false,
(awaitItem() as FollowingUiState.Topics)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
FollowingUiState.Interests(topics = testInputTopics, authors = emptyList()),
awaitItem()
)
cancel()
}
}
@Test
fun uiState_whenUnfollowingAuthors_thenShowUpdatedAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(testOutputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(
setOf(testOutputAuthors[0].author.id, testOutputAuthors[1].author.id)
)
topicsRepository.sendTopics(listOf())
topicsRepository.setFollowedTopicIds(setOf())
awaitItem()
viewModel.followAuthor(
followedAuthorId = testOutputAuthors[1].author.id,
followed = false
)
assertEquals(
FollowingUiState.Interests(topics = emptyList(), authors = testInputAuthors),
awaitItem()
)
cancel()
}
@ -129,6 +195,72 @@ private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dign
private const val TOPIC_URL = "URL"
private const val TOPIC_IMAGE_URL = "Image URL"
private val testInputAuthors = listOf(
FollowableAuthor(
Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = true
),
FollowableAuthor(
Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
),
FollowableAuthor(
Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
)
private val testOutputAuthors = listOf(
FollowableAuthor(
Author(
id = 0,
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = true
),
FollowableAuthor(
Author(
id = 1,
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = true
),
FollowableAuthor(
Author(
id = 2,
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = ""
),
isFollowed = false
)
)
private val testInputTopics = listOf(
FollowableTopic(
Topic(

@ -16,11 +16,11 @@
package com.google.samples.apps.nowinandroid.feature.topic
import com.google.samples.apps.nowinandroid.feature.topic.InterestsScreens.TOPIC_SCREEN
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinationsArgs.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreens.TOPIC_SCREEN
object TopicDestinations {
const val TOPICS_ROUTE = "topics"
object InterestsDestinations {
const val INTERESTS_ROUTE = "interests"
const val TOPIC_ROUTE = "$TOPIC_SCREEN/{$TOPIC_ID_ARG}"
}
@ -28,6 +28,6 @@ object TopicDestinationsArgs {
const val TOPIC_ID_ARG = "topicId"
}
object TopicScreens {
object InterestsScreens {
const val TOPIC_SCREEN = "topic"
}

Loading…
Cancel
Save