Add Following - Topics feature base

Acceptance criteria: https://b.corp.google.com/issues/213886281

Change-Id: I129fb3d076b831aa00c16533090cb03b2bdebdd7
pull/2/head
Simona Stojanovic 3 years ago
parent a36c0da93a
commit d355a70292

@ -63,35 +63,35 @@ class NavigationTest {
*/ */
// @Test // @Test
// fun navigateToUnselectedTabResetsContent1() { // fun navigateToUnselectedTabResetsContent1() {
// // GIVEN the user was previously on the Topics destination // // GIVEN the user was previously on the Following destination
// composeTestRule.topicsDestinationTopMatcher().performClick() // composeTestRule.followingDestinationTopMatcher().performClick()
// // and scrolled down // // and scrolled down
// [IMPLEMENT] Match the root scrollable container and scroll down to an item below the fold // [IMPLEMENT] Match the root scrollable container and scroll down to an item below the fold
// composeTestRule.topicsDestinationTopMatcher() // composeTestRule.followingDestinationTopMatcher()
// .assertDoesNotExist() // verify we scrolled beyond the top // .assertDoesNotExist() // verify we scrolled beyond the top
// // and then navigated back to the For You destination // // and then navigated back to the For You destination
// composeTestRule.forYouDestinationTopMatcher().performClick() // composeTestRule.forYouDestinationTopMatcher().performClick()
// // WHEN the user presses the Topic navigation bar item // // WHEN the user presses the Topic navigation bar item
// composeTestRule.topicsDestinationTopMatcher().performClick() // composeTestRule.followingDestinationTopMatcher().performClick()
// // THEN the Topics destination shows at the top. // // THEN the Following destination shows at the top.
// composeTestRule.topicsDestinationTopMatcher() // composeTestRule.followingDestinationTopMatcher()
// .assertExists("Screen did not correctly reset to the top after re-navigating to it") // .assertExists("Screen did not correctly reset to the top after re-navigating to it")
// } // }
// @Test // @Test
// fun navigateToUnselectedTabResetsContent2() { // fun navigateToUnselectedTabResetsContent2() {
// // GIVEN the user was previously on the Topics destination // // GIVEN the user was previously on the Following destination
// composeTestRule.topicsDestinationTopMatcher().performClick() // composeTestRule.followingDestinationTopMatcher().performClick()
// // and navigated to the Topic detail destination // // and navigated to the Topic detail destination
// [IMPLEMENT] Navigate to topic detail destination // [IMPLEMENT] Navigate to topic detail destination
// composeTestRule.topicsDestinationTopMatcher() // composeTestRule.followingDestinationTopMatcher()
// .assertDoesNotExist() // verify we are not on topics overview destination any more // .assertDoesNotExist() // verify we are not on Following overview destination any more
// // and then navigated back to the For You destination // // and then navigated back to the For You destination
// composeTestRule.forYouDestinationTopMatcher().performClick() // composeTestRule.forYouDestinationTopMatcher().performClick()
// // WHEN the user presses the Topic navigation bar item // // WHEN the user presses the Topic navigation bar item
// composeTestRule.topicsDestinationTopMatcher().performClick() // composeTestRule.followingDestinationTopMatcher().performClick()
// // THEN the Topics destination shows at the top. // // THEN the Following destination shows at the top.
// composeTestRule.topicsDestinationTopMatcher() // composeTestRule.followingDestinationTopMatcher()
// .assertExists("Screen did not correctly reset to the top after re-navigating to it") // .assertExists("Screen did not correctly reset to the top after re-navigating to it")
// } // }
@ -105,10 +105,10 @@ class NavigationTest {
// @Test // @Test
// fun reselectingTabResetsContent2() { // fun reselectingTabResetsContent2() {
// // GIVEN the user is on the Topics destination // // GIVEN the user is on the Following destination
// // and navigates to the Topic Detail destination // // and navigates to the Topic Detail destination
// // WHEN the user taps the Topics navigation bar item // // WHEN the user taps the Following navigation bar item
// // THEN the Topics destination shows at the top of the destination // // THEN the Following destination shows at the top of the destination
// } // }
/* /*
@ -122,7 +122,7 @@ class NavigationTest {
composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist() composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist()
composeTestRule.onNodeWithText("Saved").performClick() composeTestRule.onNodeWithText("Saved").performClick()
composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist() composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist()
composeTestRule.onNodeWithText("Topics").performClick() composeTestRule.onNodeWithText("Following").performClick()
composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist() composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist()
} }
@ -148,8 +148,8 @@ class NavigationTest {
fun backFromDestinationReturnsToForYou() { fun backFromDestinationReturnsToForYou() {
// GIVEN the user navigated to the Episodes destination // GIVEN the user navigated to the Episodes destination
composeTestRule.onNodeWithText("Episodes").performClick() composeTestRule.onNodeWithText("Episodes").performClick()
// and then navigated to the Topics destination // and then navigated to the Following destination
composeTestRule.onNodeWithText("Topics").performClick() composeTestRule.onNodeWithText("Following").performClick()
// WHEN the user uses the system button/gesture to go back, // WHEN the user uses the system button/gesture to go back,
Espresso.pressBack() Espresso.pressBack()
// THEN the app shows the For You destination // THEN the app shows the For You destination
@ -163,8 +163,8 @@ class NavigationTest {
private fun ComposeTestRule.forYouDestinationTopMatcher() = onNodeWithTag("FOR YOU") private fun ComposeTestRule.forYouDestinationTopMatcher() = onNodeWithTag("FOR YOU")
/* /*
* Matches an element at the top of the Topics destination. Should be updated when the * Matches an element at the top of the Following destination. Should be updated when the
* destination is implemented. * destination is implemented.
*/ */
private fun ComposeTestRule.topicsDestinationTopMatcher() = onNodeWithText("TOPICS") private fun ComposeTestRule.followingDestinationTopMatcher() = onNodeWithText("FOLLOWING")
} }

@ -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

@ -40,6 +40,25 @@ class NiaPreferences @Inject constructor(
} }
} }
suspend fun toggleFollowedTopicId(followedTopicId: Int, followed: Boolean) {
try {
userPreferences.updateData {
it.copy {
val current =
if (followed) {
followedTopicIds + followedTopicId
} else {
followedTopicIds - followedTopicId
}
this.followedTopicIds.clear()
this.followedTopicIds.addAll(current)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
val followedTopicIds: Flow<Set<Int>> = userPreferences.data val followedTopicIds: Flow<Set<Int>> = userPreferences.data
.retry { .retry {
Log.e("NiaPreferences", "Failed to read user preferences", it) Log.e("NiaPreferences", "Failed to read user preferences", it)

@ -49,97 +49,97 @@ object FakeDataSource {
{ {
"id": 0, "id": 0,
"name": "Headlines", "name": "Headlines",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 1, "id": 1,
"name": "UI", "name": "UI",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 2, "id": 2,
"name": "Testing", "name": "Testing",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 3, "id": 3,
"name": "Performance", "name": "Performance",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 4, "id": 4,
"name": "Camera & Media", "name": "Camera & Media",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 5, "id": 5,
"name": "Android Studio", "name": "Android Studio",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 6, "id": 6,
"name": "New APIs & Libraries", "name": "New APIs & Libraries",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 7, "id": 7,
"name": "Data Storage", "name": "Data Storage",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 8, "id": 8,
"name": "Kotlin", "name": "Kotlin",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 9, "id": 9,
"name": "Compose", "name": "Compose",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 10, "id": 10,
"name": "Privacy & Security", "name": "Privacy & Security",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 11, "id": 11,
"name": "Publishing & Distribution", "name": "Publishing & Distribution",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 12, "id": 12,
"name": "Tools", "name": "Tools",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 13, "id": 13,
"name": "Platform & Releases", "name": "Platform & Releases",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 14, "id": 14,
"name": "Architecture", "name": "Architecture",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 15, "id": 15,
"name": "Accessibility", "name": "Accessibility",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 16, "id": 16,
"name": "Android Auto", "name": "Android Auto",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 17, "id": 17,
"name": "Games", "name": "Games",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
}, },
{ {
"id": 18, "id": 18,
"name": "Wear OS", "name": "Wear OS",
"description": "" "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
} }
] ]
""".trimIndent() """.trimIndent()

@ -49,5 +49,8 @@ class FakeTopicsRepository @Inject constructor(
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) = override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) =
niaPreferences.setFollowedTopicIds(followedTopicIds) niaPreferences.setFollowedTopicIds(followedTopicIds)
override suspend fun toggleFollowedTopicId(followedTopicId: Int, followed: Boolean) =
niaPreferences.toggleFollowedTopicId(followedTopicId, followed)
override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds
} }

@ -35,4 +35,5 @@ data class TopicEntity(
val id: Int, val id: Int,
val name: String, val name: String,
val description: String, val description: String,
val followed: Boolean,
) )

@ -22,5 +22,6 @@ package com.google.samples.apps.nowinandroid.data.model
data class Topic( data class Topic(
val id: Int, val id: Int,
val name: String, val name: String,
val description: String val description: String,
val followed: Boolean = false
) )

@ -27,10 +27,12 @@ data class NetworkTopic(
val id: Int, val id: Int,
val name: String = "", val name: String = "",
val description: String = "", val description: String = "",
val followed: Boolean = false,
) )
fun NetworkTopic.asEntity() = TopicEntity( fun NetworkTopic.asEntity() = TopicEntity(
id = id, id = id,
name = name, name = name,
description = description description = description,
followed = followed
) )

@ -30,6 +30,11 @@ interface TopicsRepository {
*/ */
suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>)
/**
* Toggles the user's newly followed/unfollowed topic
*/
suspend fun toggleFollowedTopicId(followedTopicId: Int, followed: Boolean)
/** /**
* Returns the users currently followed topics * Returns the users currently followed topics
*/ */

@ -25,12 +25,13 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoStories import androidx.compose.material.icons.filled.AutoStories
import androidx.compose.material.icons.filled.Bookmarks import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Grid3x3
import androidx.compose.material.icons.filled.Upcoming import androidx.compose.material.icons.filled.Upcoming
import androidx.compose.material.icons.outlined.AutoStories import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Grid3x3
import androidx.compose.material.icons.outlined.Upcoming import androidx.compose.material.icons.outlined.Upcoming
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -40,6 +41,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -91,25 +93,29 @@ private fun NiABottomBar(
// Wrap the navigation bar in a surface so the color behind the system // Wrap the navigation bar in a surface so the color behind the system
// navigation is equal to the container color of the navigation bar. // navigation is equal to the container color of the navigation bar.
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
NavigationBar( CompositionLocalProvider(LocalRippleTheme provides ClearRippleTheme) {
modifier = Modifier.navigationBarsPadding().captionBarPadding(), NavigationBar(
tonalElevation = 0.dp modifier = Modifier
) { .navigationBarsPadding()
TOP_LEVEL_DESTINATIONS.forEach { dst -> .captionBarPadding(),
val selected = currentRoute == dst.route tonalElevation = 0.dp
NavigationBarItem( ) {
selected = selected, TOP_LEVEL_DESTINATIONS.forEach { dst ->
onClick = { val selected = currentRoute == dst.route
navigationActions.navigateToTopLevelDestination(dst.route) NavigationBarItem(
}, selected = selected,
icon = { onClick = {
Icon( navigationActions.navigateToTopLevelDestination(dst.route)
if (selected) dst.selectedIcon else dst.unselectedIcon, },
contentDescription = null icon = {
) Icon(
}, if (selected) dst.selectedIcon else dst.unselectedIcon,
label = { Text(stringResource(dst.iconTextId)) } contentDescription = null
) )
},
label = { Text(stringResource(dst.iconTextId)) }
)
}
} }
} }
} }
@ -142,11 +148,11 @@ private sealed class Destination(
iconTextId = R.string.saved iconTextId = R.string.saved
) )
object Topics : Destination( object Following : Destination(
route = NiaDestinations.TOPICS_ROUTE, route = NiaDestinations.FOLLOWING_ROUTE,
selectedIcon = Icons.Filled.Favorite, selectedIcon = Icons.Filled.Grid3x3,
unselectedIcon = Icons.Outlined.FavoriteBorder, unselectedIcon = Icons.Outlined.Grid3x3,
iconTextId = R.string.topics iconTextId = R.string.following
) )
} }
@ -154,5 +160,5 @@ private val TOP_LEVEL_DESTINATIONS = listOf(
Destination.ForYou, Destination.ForYou,
Destination.Episodes, Destination.Episodes,
Destination.Saved, Destination.Saved,
Destination.Topics Destination.Following
) )

@ -24,6 +24,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.google.samples.apps.nowinandroid.ui.following.FollowingRoute
import com.google.samples.apps.nowinandroid.ui.foryou.ForYouRoute import com.google.samples.apps.nowinandroid.ui.foryou.ForYouRoute
/** /**
@ -52,8 +53,17 @@ fun NiaNavGraph(
composable(NiaDestinations.SAVED_ROUTE) { composable(NiaDestinations.SAVED_ROUTE) {
Text("SAVED", modifier) Text("SAVED", modifier)
} }
composable(NiaDestinations.TOPICS_ROUTE) { composable(NiaDestinations.FOLLOWING_ROUTE) {
Text("TOPICS", modifier) FollowingRoute(
navigateToTopic = { navController.navigate(NiaDestinations.TOPIC_ROUTE) },
modifier = modifier.testTag(NiaDestinations.FOLLOWING_ROUTE),
)
}
composable(NiaDestinations.TOPIC_ROUTE) {
Text(
text = "Topic",
modifier = modifier
)
} }
} }
} }

@ -27,7 +27,8 @@ object NiaDestinations {
const val FOR_YOU_ROUTE = "for_you" const val FOR_YOU_ROUTE = "for_you"
const val EPISODES_ROUTE = "episodes" const val EPISODES_ROUTE = "episodes"
const val SAVED_ROUTE = "saved" const val SAVED_ROUTE = "saved"
const val TOPICS_ROUTE = "topics" const val FOLLOWING_ROUTE = "following"
const val TOPIC_ROUTE = "topic"
} }
/** /**

@ -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
}

@ -24,21 +24,16 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -46,6 +41,7 @@ import com.google.accompanist.flowlayout.FlowRow
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.data.model.NewsResource import com.google.samples.apps.nowinandroid.data.model.NewsResource
import com.google.samples.apps.nowinandroid.data.model.Topic import com.google.samples.apps.nowinandroid.data.model.Topic
import com.google.samples.apps.nowinandroid.ui.NiaLoadingIndicator
@Composable @Composable
fun ForYouRoute( fun ForYouRoute(
@ -72,15 +68,9 @@ fun ForYouScreen(
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
when (uiState) { when (uiState) {
ForYouFeedUiState.Loading -> { ForYouFeedUiState.Loading -> {
val forYouLoading = stringResource(id = R.string.for_you_loading) NiaLoadingIndicator(
modifier = modifier,
CircularProgressIndicator( contentDesc = stringResource(id = R.string.for_you_loading),
modifier = Modifier
.align(Alignment.Center)
.semantics {
contentDescription = forYouLoading
},
color = MaterialTheme.colorScheme.primary
) )
} }
is ForYouFeedUiState.PopulatedFeed -> { is ForYouFeedUiState.PopulatedFeed -> {

@ -18,11 +18,18 @@
<string name="for_you">For you</string> <string name="for_you">For you</string>
<string name="episodes">Episodes</string> <string name="episodes">Episodes</string>
<string name="saved">Saved</string> <string name="saved">Saved</string>
<string name="topics">Topics</string>
<string name="done">Done</string> <string name="done">Done</string>
<string name="for_you_loading">Loading for you…</string> <string name="for_you_loading">Loading for you…</string>
<!-- NewsResource Card --> <!-- NewsResource Card -->
<string name="bookmark">Bookmark</string> <string name="bookmark">Bookmark</string>
<string name="unbookmark">Unbookmark</string> <string name="unbookmark">Unbookmark</string>
<!-- Following-->
<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>
</resources> </resources>

@ -41,6 +41,15 @@ class TestTopicsRepository : TopicsRepository {
_followedTopicIds.tryEmit(followedTopicIds) _followedTopicIds.tryEmit(followedTopicIds)
} }
override suspend fun toggleFollowedTopicId(followedTopicId: Int, followed: Boolean) {
getCurrentFollowedTopics()?.let { current ->
_followedTopicIds.tryEmit(
if (followed) current.plus(followedTopicId)
else current.minus(followedTopicId)
)
}
}
override fun getFollowedTopicIdsStream(): Flow<Set<Int>> = _followedTopicIds override fun getFollowedTopicIdsStream(): Flow<Set<Int>> = _followedTopicIds
/** /**

@ -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…
Cancel
Save