diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 526dbb24a..b2ebd3aea 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -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 } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index bc44c8240..d6f85b79d 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -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 ) } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt index 201a3c3ba..505dfd8e9 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt @@ -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 diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NiaScreens.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NiaScreens.kt index 938d4a6ea..8718d33a4 100644 --- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NiaScreens.kt +++ b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NiaScreens.kt @@ -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( diff --git a/feature-following/src/androidTest/java/com/google/samples/apps/nowinandroid/following/FollowingScreenTest.kt b/feature-following/src/androidTest/java/com/google/samples/apps/nowinandroid/following/FollowingScreenTest.kt index 2b8bb1f1c..0e7ea1a29 100644 --- a/feature-following/src/androidTest/java/com/google/samples/apps/nowinandroid/following/FollowingScreenTest.kt +++ b/feature-following/src/androidTest/java/com/google/samples/apps/nowinandroid/following/FollowingScreenTest.kt @@ -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() 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 diff --git a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingItem.kt b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingItem.kt new file mode 100644 index 000000000..1c039ab75 --- /dev/null +++ b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingItem.kt @@ -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 = { } + ) + } + } +} diff --git a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt index dfd1f78c9..83ae7c4a1 100644 --- a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt +++ b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt @@ -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)) } diff --git a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingViewModel.kt b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingViewModel.kt index 7d23b2ec0..cb64d5985 100644 --- a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingViewModel.kt +++ b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingViewModel.kt @@ -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, 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 = _tabState.asStateFlow() val uiState: StateFlow = 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): Flow = - 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) : 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, + val currentIndex: Int +) + sealed interface FollowingUiState { object Loading : FollowingUiState - data class Topics(val topics: List) : FollowingUiState - object Error : FollowingUiState + + data class Interests( + val authors: List, + val topics: List + ) : FollowingUiState + + object Empty : FollowingUiState } diff --git a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/TabContent.kt b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/TabContent.kt new file mode 100644 index 000000000..a0853e80c --- /dev/null +++ b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/TabContent.kt @@ -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, + 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, + 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) + ) + } + } + } +} diff --git a/feature-following/src/main/res/values/strings.xml b/feature-following/src/main/res/values/strings.xml index a0b96b605..7b0d5ef1a 100644 --- a/feature-following/src/main/res/values/strings.xml +++ b/feature-following/src/main/res/values/strings.xml @@ -14,10 +14,11 @@ ~ limitations under the License. --> - Following - Loading topics - "Error loading topics" - Topic icon - Follow Topic button - Unfollow Topic button + Interests + Topics + People + Loading data + "No available data" + Follow interest button + Unfollow interest button diff --git a/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt b/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt index 3d78105e5..6a039d99f 100644 --- a/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt +++ b/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt @@ -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( diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/Navigation.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/Navigation.kt index 8d2a6b78e..25916bbdf 100644 --- a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/Navigation.kt +++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/Navigation.kt @@ -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" }