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 e27093302..dab4f81f9 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 @@ -63,35 +63,35 @@ class NavigationTest { */ // @Test // fun navigateToUnselectedTabResetsContent1() { -// // GIVEN the user was previously on the Topics destination -// composeTestRule.topicsDestinationTopMatcher().performClick() +// // GIVEN the user was previously on the Following destination +// composeTestRule.followingDestinationTopMatcher().performClick() // // and scrolled down // [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 // // and then navigated back to the For You destination // composeTestRule.forYouDestinationTopMatcher().performClick() // // WHEN the user presses the Topic navigation bar item -// composeTestRule.topicsDestinationTopMatcher().performClick() -// // THEN the Topics destination shows at the top. -// composeTestRule.topicsDestinationTopMatcher() +// composeTestRule.followingDestinationTopMatcher().performClick() +// // THEN the Following destination shows at the top. +// composeTestRule.followingDestinationTopMatcher() // .assertExists("Screen did not correctly reset to the top after re-navigating to it") // } // @Test // fun navigateToUnselectedTabResetsContent2() { -// // GIVEN the user was previously on the Topics destination -// composeTestRule.topicsDestinationTopMatcher().performClick() +// // GIVEN the user was previously on the Following destination +// composeTestRule.followingDestinationTopMatcher().performClick() // // and navigated to the Topic detail destination // [IMPLEMENT] Navigate to topic detail destination -// composeTestRule.topicsDestinationTopMatcher() -// .assertDoesNotExist() // verify we are not on topics overview destination any more +// composeTestRule.followingDestinationTopMatcher() +// .assertDoesNotExist() // verify we are not on Following overview destination any more // // and then navigated back to the For You destination // composeTestRule.forYouDestinationTopMatcher().performClick() // // WHEN the user presses the Topic navigation bar item -// composeTestRule.topicsDestinationTopMatcher().performClick() -// // THEN the Topics destination shows at the top. -// composeTestRule.topicsDestinationTopMatcher() +// composeTestRule.followingDestinationTopMatcher().performClick() +// // THEN the Following destination shows at the top. +// composeTestRule.followingDestinationTopMatcher() // .assertExists("Screen did not correctly reset to the top after re-navigating to it") // } @@ -105,10 +105,10 @@ class NavigationTest { // @Test // 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 -// // WHEN the user taps the Topics navigation bar item -// // THEN the Topics destination shows at the top of the destination +// // WHEN the user taps the Following navigation bar item +// // THEN the Following destination shows at the top of the destination // } /* @@ -122,7 +122,7 @@ class NavigationTest { composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist() composeTestRule.onNodeWithText("Saved").performClick() composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist() - composeTestRule.onNodeWithText("Topics").performClick() + composeTestRule.onNodeWithText("Following").performClick() composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist() } @@ -148,8 +148,8 @@ class NavigationTest { fun backFromDestinationReturnsToForYou() { // GIVEN the user navigated to the Episodes destination composeTestRule.onNodeWithText("Episodes").performClick() - // and then navigated to the Topics destination - composeTestRule.onNodeWithText("Topics").performClick() + // and then navigated to the Following destination + composeTestRule.onNodeWithText("Following").performClick() // WHEN the user uses the system button/gesture to go back, Espresso.pressBack() // THEN the app shows the For You destination @@ -163,8 +163,8 @@ class NavigationTest { 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. */ - private fun ComposeTestRule.topicsDestinationTopMatcher() = onNodeWithText("TOPICS") + private fun ComposeTestRule.followingDestinationTopMatcher() = onNodeWithText("FOLLOWING") } diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/following/FollowingScreenTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/following/FollowingScreenTest.kt new file mode 100644 index 000000000..3dd780f90 --- /dev/null +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/following/FollowingScreenTest.kt @@ -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() + + 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 diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/data/NiaPreferences.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/data/NiaPreferences.kt index 7a095c3cc..1ca429492 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/data/NiaPreferences.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/data/NiaPreferences.kt @@ -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> = userPreferences.data .retry { Log.e("NiaPreferences", "Failed to read user preferences", it) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeData.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeData.kt index 27f6e12a6..517e6e908 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeData.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeData.kt @@ -55,97 +55,97 @@ object FakeDataSource { { "id": 0, "name": "Headlines", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 1, "name": "UI", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 2, "name": "Testing", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 3, "name": "Performance", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 4, "name": "Camera & Media", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 5, "name": "Android Studio", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 6, "name": "New APIs & Libraries", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 7, "name": "Data Storage", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 8, "name": "Kotlin", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 9, "name": "Compose", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 10, "name": "Privacy & Security", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 11, "name": "Publishing & Distribution", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 12, "name": "Tools", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 13, "name": "Platform & Releases", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 14, "name": "Architecture", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 15, "name": "Accessibility", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 16, "name": "Android Auto", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 17, "name": "Games", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." }, { "id": 18, "name": "Wear OS", - "description": "" + "description": "At vero eos et accusamus et iusto odio dignissimos ducimus qui." } ] """.trimIndent() diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeTopicsRepository.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeTopicsRepository.kt index 2a83d2cac..02f6ebd08 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeTopicsRepository.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/data/fake/FakeTopicsRepository.kt @@ -49,5 +49,8 @@ class FakeTopicsRepository @Inject constructor( override suspend fun setFollowedTopicIds(followedTopicIds: Set) = niaPreferences.setFollowedTopicIds(followedTopicIds) + override suspend fun toggleFollowedTopicId(followedTopicId: Int, followed: Boolean) = + niaPreferences.toggleFollowedTopicId(followedTopicId, followed) + override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/data/local/entities/TopicEntity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/data/local/entities/TopicEntity.kt index eca05fd77..6829acfc4 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/data/local/entities/TopicEntity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/data/local/entities/TopicEntity.kt @@ -35,4 +35,5 @@ data class TopicEntity( val id: Int, val name: String, val description: String, + val followed: Boolean, ) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/data/model/Topic.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/data/model/Topic.kt index 8b3d20740..4b1513b1b 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/data/model/Topic.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/data/model/Topic.kt @@ -22,5 +22,6 @@ package com.google.samples.apps.nowinandroid.data.model data class Topic( val id: Int, val name: String, - val description: String + val description: String, + val followed: Boolean = false ) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/data/network/NetworkTopic.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/data/network/NetworkTopic.kt index 7b12ea8ae..3ee55b7ce 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/data/network/NetworkTopic.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/data/network/NetworkTopic.kt @@ -27,10 +27,12 @@ data class NetworkTopic( val id: Int, val name: String = "", val description: String = "", + val followed: Boolean = false, ) fun NetworkTopic.asEntity() = TopicEntity( id = id, name = name, - description = description + description = description, + followed = followed ) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/data/repository/TopicsRepository.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/data/repository/TopicsRepository.kt index 696020e1c..0824bcaaa 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/data/repository/TopicsRepository.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/data/repository/TopicsRepository.kt @@ -30,6 +30,11 @@ interface TopicsRepository { */ suspend fun setFollowedTopicIds(followedTopicIds: Set) + /** + * Toggles the user's newly followed/unfollowed topic + */ + suspend fun toggleFollowedTopicId(followedTopicId: Int, followed: Boolean) + /** * Returns the users currently followed topics */ 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 d55678267..f4d7333ba 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 @@ -25,12 +25,13 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoStories 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.outlined.AutoStories 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.ripple.LocalRippleTheme import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -40,6 +41,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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 // navigation is equal to the container color of the navigation bar. Surface(color = MaterialTheme.colorScheme.surface) { - NavigationBar( - modifier = Modifier.navigationBarsPadding().captionBarPadding(), - tonalElevation = 0.dp - ) { - TOP_LEVEL_DESTINATIONS.forEach { dst -> - val selected = currentRoute == dst.route - NavigationBarItem( - selected = selected, - onClick = { - navigationActions.navigateToTopLevelDestination(dst.route) - }, - icon = { - Icon( - if (selected) dst.selectedIcon else dst.unselectedIcon, - contentDescription = null - ) - }, - label = { Text(stringResource(dst.iconTextId)) } - ) + CompositionLocalProvider(LocalRippleTheme provides ClearRippleTheme) { + NavigationBar( + modifier = Modifier + .navigationBarsPadding() + .captionBarPadding(), + tonalElevation = 0.dp + ) { + TOP_LEVEL_DESTINATIONS.forEach { dst -> + val selected = currentRoute == dst.route + NavigationBarItem( + selected = selected, + onClick = { + navigationActions.navigateToTopLevelDestination(dst.route) + }, + icon = { + Icon( + if (selected) dst.selectedIcon else dst.unselectedIcon, + contentDescription = null + ) + }, + label = { Text(stringResource(dst.iconTextId)) } + ) + } } } } @@ -142,11 +148,11 @@ private sealed class Destination( iconTextId = R.string.saved ) - object Topics : Destination( - route = NiaDestinations.TOPICS_ROUTE, - selectedIcon = Icons.Filled.Favorite, - unselectedIcon = Icons.Outlined.FavoriteBorder, - iconTextId = R.string.topics + object Following : Destination( + route = NiaDestinations.FOLLOWING_ROUTE, + selectedIcon = Icons.Filled.Grid3x3, + unselectedIcon = Icons.Outlined.Grid3x3, + iconTextId = R.string.following ) } @@ -154,5 +160,5 @@ private val TOP_LEVEL_DESTINATIONS = listOf( Destination.ForYou, Destination.Episodes, Destination.Saved, - Destination.Topics + Destination.Following ) 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 875cd69ed..88411ceb8 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 @@ -24,6 +24,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.google.samples.apps.nowinandroid.ui.following.FollowingRoute import com.google.samples.apps.nowinandroid.ui.foryou.ForYouRoute /** @@ -52,8 +53,17 @@ fun NiaNavGraph( composable(NiaDestinations.SAVED_ROUTE) { Text("SAVED", modifier) } - composable(NiaDestinations.TOPICS_ROUTE) { - Text("TOPICS", modifier) + composable(NiaDestinations.FOLLOWING_ROUTE) { + FollowingRoute( + navigateToTopic = { navController.navigate(NiaDestinations.TOPIC_ROUTE) }, + modifier = modifier.testTag(NiaDestinations.FOLLOWING_ROUTE), + ) + } + composable(NiaDestinations.TOPIC_ROUTE) { + Text( + text = "Topic", + modifier = modifier + ) } } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt index 28ffbb532..33787a716 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt @@ -27,7 +27,8 @@ object NiaDestinations { const val FOR_YOU_ROUTE = "for_you" const val EPISODES_ROUTE = "episodes" const val SAVED_ROUTE = "saved" - const val TOPICS_ROUTE = "topics" + const val FOLLOWING_ROUTE = "following" + const val TOPIC_ROUTE = "topic" } /** diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaScreens.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaScreens.kt new file mode 100644 index 000000000..d71591396 --- /dev/null +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaScreens.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/following/FollowingScreen.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/following/FollowingScreen.kt new file mode 100644 index 000000000..452b4aed4 --- /dev/null +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/following/FollowingScreen.kt @@ -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 = { _, _ -> } + ) + } + } +} diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/following/FollowingViewModel.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/following/FollowingViewModel.kt new file mode 100644 index 000000000..d0a8c8b42 --- /dev/null +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/following/FollowingViewModel.kt @@ -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 = 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): Flow = + 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) : FollowingState + object Error : FollowingState +} + +sealed interface FollowingUiState { + object Loading : FollowingUiState + data class Topics(val topics: List) : FollowingUiState + object Error : FollowingUiState +} diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/foryou/ForYouScreen.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/foryou/ForYouScreen.kt index 0034517da..093aabe9a 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/foryou/ForYouScreen.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/foryou/ForYouScreen.kt @@ -24,21 +24,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.CircularProgressIndicator import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.unit.dp 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.data.model.NewsResource import com.google.samples.apps.nowinandroid.data.model.Topic +import com.google.samples.apps.nowinandroid.ui.NiaLoadingIndicator @Composable fun ForYouRoute( @@ -72,15 +68,9 @@ fun ForYouScreen( Box(modifier = modifier.fillMaxSize()) { when (uiState) { ForYouFeedUiState.Loading -> { - val forYouLoading = stringResource(id = R.string.for_you_loading) - - CircularProgressIndicator( - modifier = Modifier - .align(Alignment.Center) - .semantics { - contentDescription = forYouLoading - }, - color = MaterialTheme.colorScheme.primary + NiaLoadingIndicator( + modifier = modifier, + contentDesc = stringResource(id = R.string.for_you_loading), ) } is ForYouFeedUiState.PopulatedFeed -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c91267fd..4816f08d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,11 +18,18 @@ For you Episodes Saved - Topics Done Loading for you… Bookmark Unbookmark + + + Following + Loading topics + "Error loading topics" + Topic icon + Follow Topic button + Unfollow Topic button diff --git a/app/src/test/java/com/google/samples/apps/nowinandroid/testutil/TestTopicsRepository.kt b/app/src/test/java/com/google/samples/apps/nowinandroid/testutil/TestTopicsRepository.kt index bb60d885f..1f82b790c 100644 --- a/app/src/test/java/com/google/samples/apps/nowinandroid/testutil/TestTopicsRepository.kt +++ b/app/src/test/java/com/google/samples/apps/nowinandroid/testutil/TestTopicsRepository.kt @@ -41,6 +41,15 @@ class TestTopicsRepository : TopicsRepository { _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> = _followedTopicIds /** diff --git a/app/src/test/java/com/google/samples/apps/nowinandroid/ui/following/FollowingViewModelTest.kt b/app/src/test/java/com/google/samples/apps/nowinandroid/ui/following/FollowingViewModelTest.kt new file mode 100644 index 000000000..76d6a586e --- /dev/null +++ b/app/src/test/java/com/google/samples/apps/nowinandroid/ui/following/FollowingViewModelTest.kt @@ -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 + ) +)