From 6d77b0c61df479ad081f3a04278476dcdb11d5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Fri, 26 May 2023 13:24:57 +0200 Subject: [PATCH] Implement list/detail view for interests and topics Change-Id: Idc1bcb00882d9e7b60baa620c05d07e6721a4984 --- app/build.gradle.kts | 1 - .../apps/nowinandroid/ui/NavigationTest.kt | 18 -- .../apps/nowinandroid/ui/NiaAppStateTest.kt | 2 +- .../nowinandroid/navigation/NiaNavHost.kt | 33 +-- .../samples/apps/nowinandroid/ui/NiaApp.kt | 13 +- .../apps/nowinandroid/ui/NiaAppState.kt | 37 ++- .../feature/bookmarks/BookmarksScreenTest.kt | 5 + .../feature/bookmarks/BookmarksScreen.kt | 30 +- .../navigation/BookmarksNavigation.kt | 8 +- .../interests/InterestsScreenTest.kt | 7 +- .../feature/interests/InterestsItem.kt | 112 ++++--- .../feature/interests/InterestsRoute.kt | 97 ++++++ .../feature/interests/InterestsScreen.kt | 31 +- .../feature/interests/InterestsUiState.kt | 30 ++ .../feature/interests/InterestsViewModel.kt | 111 ++++++- .../feature/interests/NewsUiState.kt} | 20 +- .../feature/interests/TabContent.kt | 40 ++- .../feature/interests}/TopicScreen.kt | 156 ++++------ .../feature/interests/TopicUiState.kt | 30 ++ .../navigation/InterestsNavigation.kt | 35 ++- .../interests/src/main/res/values/strings.xml | 2 + .../interests/InterestsViewModelTest.kt | 158 +++++----- .../nowinandroid/interests/TopicsTestData.kt | 100 +++++++ .../feature/search/SearchScreenTest.kt | 31 +- .../feature/search/SearchScreen.kt | 1 + feature/topic/.gitignore | 1 - feature/topic/README.md | 3 - .../feature/topic/TopicScreenTest.kt | 140 --------- feature/topic/src/main/AndroidManifest.xml | 19 -- .../feature/topic/TopicViewModel.kt | 190 ------------ .../topic/navigation/TopicNavigation.kt | 57 ---- feature/topic/src/main/res/values/strings.xml | 20 -- .../feature/topic/TopicViewModelTest.kt | 275 ------------------ settings.gradle.kts | 1 - 34 files changed, 686 insertions(+), 1128 deletions(-) create mode 100644 feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsRoute.kt create mode 100644 feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsUiState.kt rename feature/{topic/build.gradle.kts => interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/NewsUiState.kt} (59%) rename feature/{topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic => interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests}/TopicScreen.kt (66%) create mode 100644 feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TopicUiState.kt create mode 100644 feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/TopicsTestData.kt delete mode 100644 feature/topic/.gitignore delete mode 100644 feature/topic/README.md delete mode 100644 feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt delete mode 100644 feature/topic/src/main/AndroidManifest.xml delete mode 100644 feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt delete mode 100644 feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt delete mode 100644 feature/topic/src/main/res/values/strings.xml delete mode 100644 feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c6e3eeb96..cbb30b6f14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,7 +83,6 @@ dependencies { implementation(project(":feature:interests")) implementation(project(":feature:foryou")) implementation(project(":feature:bookmarks")) - implementation(project(":feature:topic")) implementation(project(":feature:search")) implementation(project(":feature:settings")) 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 5aa3ab02ed..40173ae4e5 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 @@ -242,22 +242,4 @@ class NavigationTest { onNodeWithText(forYou).assertExists() } } - - @Test - fun navigationBar_multipleBackStackInterests() { - composeTestRule.apply { - onNodeWithText(interests).performClick() - // TODO: Grab string from fake data - onNodeWithText("Android Studio & Tools").performClick() - - // Switch tab - onNodeWithText(forYou).performClick() - - // Come back to Interests - onNodeWithText(interests).performClick() - - // Verify we're not in the list of interests - onNodeWithText("Android Auto").assertDoesNotExist() // TODO: Grab string from fake data - } - } } diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index 2457af9006..2bf2196650 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -82,7 +82,7 @@ class NiaAppStateTest { } // Update currentDestination whenever it changes - currentDestination = state.currentDestination?.route + currentDestination = state.currentBackStackEntry?.destination?.route // Navigate to destination b once LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index e43dfaba7e..f94d697ac8 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -16,6 +16,8 @@ package com.google.samples.apps.nowinandroid.navigation +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost @@ -23,10 +25,8 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmar import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph +import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen -import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic -import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen -import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.ui.NiaAppState /** @@ -39,33 +39,32 @@ import com.google.samples.apps.nowinandroid.ui.NiaAppState @Composable fun NiaNavHost( appState: NiaAppState, + snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, startDestination: String = forYouNavigationRoute, ) { val navController = appState.navController + val interestsScrollState = rememberLazyGridState() NavHost( navController = navController, startDestination = startDestination, modifier = modifier, ) { - // TODO: handle topic clicks from each top level destination - forYouScreen(onTopicClick = {}) - bookmarksScreen(onTopicClick = {}) + forYouScreen(onTopicClick = navController::navigateToInterests) + bookmarksScreen( + onTopicClick = navController::navigateToInterests, + snackbarHostState = snackbarHostState, + ) searchScreen( onBackClick = navController::popBackStack, - onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, - onTopicClick = navController::navigateToTopic, + onInterestsClick = navController::navigateToInterests, + onTopicClick = navController::navigateToInterests, ) interestsGraph( - onTopicClick = { topicId -> - navController.navigateToTopic(topicId) - }, - nestedGraphs = { - topicScreen( - onBackClick = navController::popBackStack, - onTopicClick = {}, - ) - }, + listState = interestsScrollState, + shouldShowTwoPane = appState.shouldShowTwoPane, + onTopicClick = navController::navigateToInterests, + onBackClick = navController::navigateToInterests, ) } } 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 6f6ab06034..aa54b00991 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 @@ -73,6 +73,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVec import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors +import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination @@ -94,15 +95,13 @@ fun NiaApp( userNewsResourceRepository = userNewsResourceRepository, ), ) { - val shouldShowGradientBackground = - appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU var showSettingsDialog by rememberSaveable { mutableStateOf(false) } NiaBackground { NiaGradientBackground( - gradientColors = if (shouldShowGradientBackground) { + gradientColors = if (appState.shouldShowGradientBackground) { LocalGradientColors.current } else { GradientColors() @@ -144,7 +143,7 @@ fun NiaApp( destinations = appState.topLevelDestinations, destinationsWithUnreadResources = unreadDestinations, onNavigateToDestination = appState::navigateToTopLevelDestination, - currentDestination = appState.currentDestination, + currentDestination = appState.currentBackStackEntry?.destination, modifier = Modifier.testTag("NiaBottomBar"), ) } @@ -165,7 +164,7 @@ fun NiaApp( NiaNavRail( destinations = appState.topLevelDestinations, onNavigateToDestination = appState::navigateToTopLevelDestination, - currentDestination = appState.currentDestination, + currentDestination = appState.currentBackStackEntry?.destination, modifier = Modifier .testTag("NiaNavRail") .safeDrawingPadding(), @@ -190,11 +189,11 @@ fun NiaApp( containerColor = Color.Transparent, ), onActionClick = { showSettingsDialog = true }, - onNavigationClick = { appState.navigateToSearch() }, + onNavigationClick = { appState.navController.navigateToSearch() }, ) } - NiaNavHost(appState) + NiaNavHost(appState, snackbarHostState) } // TODO: We may want to add padding or spacer when the snackbar is shown so that diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 09e70069e0..4436641578 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -16,14 +16,15 @@ package com.google.samples.apps.nowinandroid.ui +import android.os.Bundle import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController -import androidx.navigation.NavDestination import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -38,8 +39,8 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigat import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute -import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph -import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch +import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests +import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU @@ -85,17 +86,24 @@ class NiaAppState( networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, ) { - val currentDestination: NavDestination? + val currentBackStackEntry: NavBackStackEntry? @Composable get() = navController - .currentBackStackEntryAsState().value?.destination + .currentBackStackEntryAsState().value val currentTopLevelDestination: TopLevelDestination? - @Composable get() = when (currentDestination?.route) { - forYouNavigationRoute -> FOR_YOU - bookmarksRoute -> BOOKMARKS - interestsRoute -> INTERESTS - else -> null + @Composable get() { + val route: String? = currentBackStackEntry?.destination?.route + val arguments: Bundle? = currentBackStackEntry?.arguments + return when { + route == forYouNavigationRoute -> FOR_YOU + route == bookmarksRoute -> BOOKMARKS + route == interestsRoute && + (arguments?.getString(topicIdArg) == null || shouldShowTwoPane) -> INTERESTS + else -> null + } } + val shouldShowGradientBackground: Boolean + @Composable get() = currentBackStackEntry?.destination?.route == forYouNavigationRoute val shouldShowBottomBar: Boolean get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact @@ -103,6 +111,9 @@ class NiaAppState( val shouldShowNavRail: Boolean get() = !shouldShowBottomBar + val shouldShowTwoPane: Boolean + get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded + val isOffline = networkMonitor.isOnline .map(Boolean::not) .stateIn( @@ -159,14 +170,10 @@ class NiaAppState( when (topLevelDestination) { FOR_YOU -> navController.navigateToForYou(topLevelNavOptions) BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions) - INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions) + INTERESTS -> navController.navigateToInterests(navOptions = topLevelNavOptions) } } } - - fun navigateToSearch() { - navController.navigateToSearch() - } } /** diff --git a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt index 680c6dcf75..71f30bb0c3 100644 --- a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.activity.ComponentActivity +import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.filter @@ -50,6 +51,7 @@ class BookmarksScreenTest { composeTestRule.setContent { BookmarksScreen( feedState = NewsFeedUiState.Loading, + snackbarHostState = SnackbarHostState(), removeFromBookmarks = {}, onTopicClick = {}, onNewsResourceViewed = {}, @@ -70,6 +72,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Success( userNewsResourcesTestData.take(2), ), + snackbarHostState = SnackbarHostState(), removeFromBookmarks = {}, onTopicClick = {}, onNewsResourceViewed = {}, @@ -110,6 +113,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Success( userNewsResourcesTestData.take(2), ), + snackbarHostState = SnackbarHostState(), removeFromBookmarks = { newsResourceId -> assertEquals(userNewsResourcesTestData[0].id, newsResourceId) removeFromBookmarksCalled = true @@ -144,6 +148,7 @@ class BookmarksScreenTest { composeTestRule.setContent { BookmarksScreen( feedState = NewsFeedUiState.Success(emptyList()), + snackbarHostState = SnackbarHostState(), removeFromBookmarks = {}, onTopicClick = {}, onNewsResourceViewed = {}, diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 25412e851b..9f0e89a63c 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -35,11 +35,8 @@ import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration.Short -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult.ActionPerformed import androidx.compose.material3.Text @@ -47,7 +44,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -79,12 +75,14 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed @Composable internal fun BookmarksRoute( onTopicClick: (String) -> Unit, + snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, viewModel: BookmarksViewModel = hiltViewModel(), ) { val feedState by viewModel.feedUiState.collectAsStateWithLifecycle() BookmarksScreen( feedState = feedState, + snackbarHostState = snackbarHostState, removeFromBookmarks = viewModel::removeFromSavedResources, onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, @@ -98,11 +96,11 @@ internal fun BookmarksRoute( /** * Displays the user's bookmarked articles. Includes support for loading and empty states. */ -@OptIn(ExperimentalMaterial3Api::class) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @Composable internal fun BookmarksScreen( feedState: NewsFeedUiState, + snackbarHostState: SnackbarHostState, removeFromBookmarks: (String) -> Unit, onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, @@ -113,7 +111,6 @@ internal fun BookmarksScreen( ) { val bookmarkRemovedMessage = stringResource(id = R.string.bookmark_removed) val undoText = stringResource(id = R.string.undo) - val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) { @@ -140,20 +137,19 @@ internal fun BookmarksScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - Scaffold(snackbarHost = { SnackbarHost(hostState = snackbarHostState) }) { - Box( - modifier = Modifier.padding(it).fillMaxSize(), - ) { - when (feedState) { - Loading -> LoadingState(modifier) - is Success -> if (feedState.feed.isNotEmpty()) { - BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) - } else { - EmptyState(modifier) - } + Box( + modifier = Modifier.fillMaxSize(), + ) { + when (feedState) { + Loading -> LoadingState(modifier) + is Success -> if (feedState.feed.isNotEmpty()) { + BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) + } else { + EmptyState(modifier) } } } + TrackScreenViewEvent(screenName = "Saved") } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt index eeb7f1576b..9e058ded4a 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks.navigation +import androidx.compose.material3.SnackbarHostState import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions @@ -28,8 +29,11 @@ fun NavController.navigateToBookmarks(navOptions: NavOptions? = null) { this.navigate(bookmarksRoute, navOptions) } -fun NavGraphBuilder.bookmarksScreen(onTopicClick: (String) -> Unit) { +fun NavGraphBuilder.bookmarksScreen( + onTopicClick: (String) -> Unit, + snackbarHostState: SnackbarHostState, +) { composable(route = bookmarksRoute) { - BookmarksRoute(onTopicClick) + BookmarksRoute(onTopicClick, snackbarHostState) } } diff --git a/feature/interests/src/androidTest/java/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt b/feature/interests/src/androidTest/java/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt index 492e91fa37..a6804be312 100644 --- a/feature/interests/src/androidTest/java/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt +++ b/feature/interests/src/androidTest/java/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.interests import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed @@ -74,7 +75,10 @@ class InterestsScreenTest { fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() { composeTestRule.setContent { InterestsScreen( - uiState = InterestsUiState.Interests(topics = followableTopicTestData), + uiState = InterestsUiState.Interests( + topics = followableTopicTestData, + selectedTopicId = null, + ), ) } @@ -107,6 +111,7 @@ class InterestsScreenTest { @Composable private fun InterestsScreen(uiState: InterestsUiState) { InterestsScreen( + listState = rememberLazyListState(), uiState = uiState, followTopic = { _, _ -> }, onTopicClick = {}, diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt index ec9fd8f107..ed1a3901a8 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt @@ -17,23 +17,21 @@ package com.google.samples.apps.nowinandroid.feature.interests import androidx.compose.foundation.background -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.foundation.selection.selectable import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.semantics import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton @@ -43,6 +41,7 @@ import com.google.samples.apps.nowinandroid.feature.interests.R.string @Composable fun InterestsItem( + isSelected: Boolean, name: String, following: Boolean, topicImageUrl: String, @@ -51,63 +50,50 @@ fun InterestsItem( 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), - ) { + ListItem( + leadingContent = { InterestsIcon(topicImageUrl, iconModifier.size(64.dp)) - Spacer(modifier = Modifier.width(24.dp)) - InterestContent(name, description) - } - NiaIconToggleButton( - checked = following, - onCheckedChange = onFollowButtonClick, - icon = { - Icon( - imageVector = NiaIcons.Add, - contentDescription = stringResource( - id = string.card_follow_button_content_desc, - ), - ) - }, - checkedIcon = { - Icon( - imageVector = NiaIcons.Check, - contentDescription = stringResource( - id = string.card_unfollow_button_content_desc, - ), - ) - }, - ) - } -} - -@Composable -private fun InterestContent(name: String, description: String, modifier: Modifier = Modifier) { - Column(modifier) { - Text( - text = name, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding( - vertical = if (description.isEmpty()) 0.dp else 4.dp, - ), - ) - if (description.isNotEmpty()) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, + }, + headlineText = { + Text(text = name) + }, + supportingText = { + Text(text = description) + }, + trailingContent = { + NiaIconToggleButton( + checked = following, + onCheckedChange = onFollowButtonClick, + icon = { + Icon( + imageVector = NiaIcons.Add, + contentDescription = stringResource( + id = string.card_follow_button_content_desc, + ), + ) + }, + checkedIcon = { + Icon( + imageVector = NiaIcons.Check, + contentDescription = stringResource( + id = string.card_unfollow_button_content_desc, + ), + ) + }, ) - } - } + }, + modifier = modifier + .semantics(mergeDescendants = true) { /* no-op */ } + .selectable(selected = isSelected, onClick = onClick), + colors = ListItemDefaults.colors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.surface + } else { + Color.Transparent + }, + ), + ) } @Composable @@ -135,6 +121,7 @@ private fun InterestsCardPreview() { NiaTheme { Surface { InterestsItem( + isSelected = false, name = "Compose", description = "Description", following = false, @@ -152,6 +139,7 @@ private fun InterestsCardLongNamePreview() { NiaTheme { Surface { InterestsItem( + isSelected = false, name = "This is a very very very very long name", description = "Description", following = true, @@ -169,6 +157,7 @@ private fun InterestsCardLongDescriptionPreview() { NiaTheme { Surface { InterestsItem( + isSelected = false, name = "Compose", description = "This is a very very very very very very very " + "very very very long description", @@ -187,6 +176,7 @@ private fun InterestsCardWithEmptyDescriptionPreview() { NiaTheme { Surface { InterestsItem( + isSelected = false, name = "Compose", description = "", following = true, diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsRoute.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsRoute.kt new file mode 100644 index 0000000000..06f3a3a692 --- /dev/null +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsRoute.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2023 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.interests + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +internal fun InterestsRoute( + listState: LazyGridState, + shouldShowTwoPane: Boolean, + onTopicClick: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: InterestsViewModel = hiltViewModel(), +) { + val interestUiState by viewModel.interestUiState.collectAsStateWithLifecycle() + val topicUiState by viewModel.topicUiState.collectAsStateWithLifecycle() + + Row(modifier = modifier.fillMaxSize()) { + if (shouldShowTwoPane || topicUiState == null) { + Box( + modifier = Modifier + .fillMaxHeight() + .then( + if (topicUiState != null) { + Modifier.widthIn(min = 350.dp) + } else { + Modifier.weight(1f) + }, + ), + ) { + InterestsScreen( + uiState = interestUiState, + listState = listState, + followTopic = viewModel::followTopic, + onTopicClick = onTopicClick, + modifier = Modifier.matchParentSize(), + ) + } + } + AnimatedVisibility( + visible = topicUiState != null, + enter = slideInHorizontally(initialOffsetX = { it / 2 }), + exit = slideOutHorizontally(targetOffsetX = { it / 2 }), + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .run { + if (!shouldShowTwoPane) { + safeDrawingPadding() + } else { + this + } + }, + ) { + topicUiState?.let { state -> + TopicScreen( + topicUiState = state, + onBackClick = onBackClick, + onFollowClick = viewModel::followTopic, + onTopicClick = onTopicClick, + onBookmarkChanged = viewModel::bookmarkNews, + onNewsResourceViewed = viewModel::newsViewed, + ) + } + } + } +} diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index e618c1c9f2..c1a89b7137 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -17,15 +17,14 @@ package com.google.samples.apps.nowinandroid.feature.interests import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme @@ -33,28 +32,14 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent - -@Composable -internal fun InterestsRoute( - onTopicClick: (String) -> Unit, - modifier: Modifier = Modifier, - viewModel: InterestsViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - InterestsScreen( - uiState = uiState, - followTopic = viewModel::followTopic, - onTopicClick = onTopicClick, - modifier = modifier, - ) -} +import com.google.samples.apps.nowinandroid.feature.interests.R.string @Composable internal fun InterestsScreen( uiState: InterestsUiState, followTopic: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, + listState: LazyGridState = rememberLazyGridState(), modifier: Modifier = Modifier, ) { Column( @@ -65,15 +50,18 @@ internal fun InterestsScreen( InterestsUiState.Loading -> NiaLoadingWheel( modifier = modifier, - contentDesc = stringResource(id = R.string.loading), + contentDesc = stringResource(id = string.loading), ) + is InterestsUiState.Interests -> TopicsTabContent( + selectedTopicId = uiState.selectedTopicId, + listState = listState, topics = uiState.topics, onTopicClick = onTopicClick, onFollowButtonClick = followTopic, - modifier = modifier, ) + is InterestsUiState.Empty -> InterestsEmptyScreen() } } @@ -96,6 +84,7 @@ fun InterestsScreenPopulated( InterestsScreen( uiState = InterestsUiState.Interests( topics = followableTopics, + selectedTopicId = null, ), followTopic = { _, _ -> }, onTopicClick = {}, diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsUiState.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsUiState.kt new file mode 100644 index 0000000000..650319aa3e --- /dev/null +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsUiState.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 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.interests + +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic + +sealed interface InterestsUiState { + object Loading : InterestsUiState + + data class Interests( + val topics: List, + val selectedTopicId: String?, + ) : InterestsUiState + + object Empty : InterestsUiState +} diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt index debc49bcd7..d0421b3b00 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt @@ -16,16 +16,29 @@ package com.google.samples.apps.nowinandroid.feature.interests +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.TopicSortField 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.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg import dagger.hilt.android.lifecycle.HiltViewModel +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.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -34,30 +47,98 @@ import javax.inject.Inject class InterestsViewModel @Inject constructor( val userDataRepository: UserDataRepository, getFollowableTopics: GetFollowableTopicsUseCase, + userNewsResourceRepository: UserNewsResourceRepository, + topicsRepository: TopicsRepository, + savedStateHandle: SavedStateHandle, ) : ViewModel() { - val uiState: StateFlow = - getFollowableTopics(sortBy = TopicSortField.NAME).map( - InterestsUiState::Interests, - ).stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = InterestsUiState.Loading, + private val topicId: StateFlow = + savedStateHandle.getStateFlow(topicIdArg, null) + + val interestUiState: StateFlow = combine( + getFollowableTopics(sortBy = TopicSortField.NAME), + topicId, + ) { topics, selectedTopicId -> + InterestsUiState.Interests( + topics = topics, + selectedTopicId = selectedTopicId, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InterestsUiState.Loading, + ) + + val topicUiState: StateFlow = topicId.flatMapLatest { topicId -> + topicUiState( + topicId, + userDataRepository, + userNewsResourceRepository, + topicsRepository, ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null, + ) + + fun followTopic(followedTopicId: String, isFollowed: Boolean) { + viewModelScope.launch { + userDataRepository.toggleFollowedTopicId(followedTopicId, isFollowed) + } + } + + fun bookmarkNews(newsResourceId: String, isBookmarked: Boolean) { + viewModelScope.launch { + userDataRepository.updateNewsResourceBookmark(newsResourceId, isBookmarked) + } + } - fun followTopic(followedTopicId: String, followed: Boolean) { + fun newsViewed(newsResourceId: String) { viewModelScope.launch { - userDataRepository.toggleFollowedTopicId(followedTopicId, followed) + userDataRepository.setNewsResourceViewed(newsResourceId, true) } } } -sealed interface InterestsUiState { - object Loading : InterestsUiState +private fun topicUiState( + topicId: String?, + userDataRepository: UserDataRepository, + userNewsResourceRepository: UserNewsResourceRepository, + topicsRepository: TopicsRepository, +): Flow { + if (topicId == null) { + return flowOf(null) + } + + // Observe the followed topics, as they could change over time. + val followedTopicIds: Flow> = + userDataRepository.userData + .map { it.followedTopics } + + // Observe topic information + val topicStream: Flow = topicsRepository.getTopic(id = topicId) - data class Interests( - val topics: List, - ) : InterestsUiState + val newsResourcesStream: Flow> = userNewsResourceRepository.observeAll( + NewsResourceQuery(filterTopicIds = setOf(element = topicId)), + ) - object Empty : InterestsUiState + return combine<_, _, _, TopicUiState>( + followedTopicIds, + topicStream, + newsResourcesStream, + ) { followedTopics, topic, newsResources -> + val followed = followedTopics.contains(topicId) + TopicUiState.Success( + followableTopic = FollowableTopic( + topic = topic, + isFollowed = followed, + ), + newsResources = newsResources, + ) + }.onStart { + emit(TopicUiState.Loading) + }.catch { + emit(TopicUiState.Error) + } } diff --git a/feature/topic/build.gradle.kts b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/NewsUiState.kt similarity index 59% rename from feature/topic/build.gradle.kts rename to feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/NewsUiState.kt index 6bacd8343f..6c8ef11a25 100644 --- a/feature/topic/build.gradle.kts +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/NewsUiState.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 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. @@ -14,18 +14,12 @@ * limitations under the License. */ -import com.android.build.api.dsl.ManagedVirtualDevice +package com.google.samples.apps.nowinandroid.feature.interests -plugins { - id("nowinandroid.android.feature") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") -} +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource -android { - namespace = "com.google.samples.apps.nowinandroid.feature.topic" +sealed interface NewsUiState { + data class Success(val news: List) : NewsUiState + object Error : NewsUiState + object Loading : NewsUiState } - -dependencies { - implementation(libs.kotlinx.datetime) -} \ No newline at end of file diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index d55cd9a38b..bb48c05b6d 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -19,10 +19,14 @@ package com.google.samples.apps.nowinandroid.feature.interests import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -31,30 +35,36 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @Composable fun TopicsTabContent( + selectedTopicId: String?, topics: List, onTopicClick: (String) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, + listState: LazyGridState = rememberLazyGridState(), withBottomSpacer: Boolean = true, ) { - LazyColumn( + LazyVerticalGrid( + columns = GridCells.Adaptive(300.dp), + state = listState, modifier = modifier - .padding(horizontal = 24.dp) + .selectableGroup() .testTag("interests:topics"), contentPadding = PaddingValues(vertical = 16.dp), ) { - topics.forEach { followableTopic -> + items( + items = topics, + key = { item -> item.topic.id }, + ) { followableTopic -> val topicId = followableTopic.topic.id - item(key = topicId) { - InterestsItem( - name = followableTopic.topic.name, - following = followableTopic.isFollowed, - description = followableTopic.topic.shortDescription, - topicImageUrl = followableTopic.topic.imageUrl, - onClick = { onTopicClick(topicId) }, - onFollowButtonClick = { onFollowButtonClick(topicId, it) }, - ) - } + InterestsItem( + isSelected = selectedTopicId == topicId, + name = followableTopic.topic.name, + following = followableTopic.isFollowed, + description = followableTopic.topic.shortDescription, + topicImageUrl = followableTopic.topic.imageUrl, + onClick = { onTopicClick(topicId) }, + onFollowButtonClick = { onFollowButtonClick(topicId, it) }, + ) } if (withBottomSpacer) { diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TopicScreen.kt similarity index 66% rename from feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt rename to feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TopicScreen.kt index fd408f9cf0..9f81a2cad7 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TopicScreen.kt @@ -14,11 +14,11 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic +package com.google.samples.apps.nowinandroid.feature.interests -import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -27,24 +27,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsTopHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip @@ -54,58 +54,37 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews -import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider -import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems -import com.google.samples.apps.nowinandroid.feature.topic.R.string -import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading +import com.google.samples.apps.nowinandroid.core.ui.newsFeed +import com.google.samples.apps.nowinandroid.feature.interests.R.string +import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState.Error +import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState.Loading +import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState.Success -@Composable -internal fun TopicRoute( - onBackClick: () -> Unit, - onTopicClick: (String) -> Unit, - modifier: Modifier = Modifier, - viewModel: TopicViewModel = hiltViewModel(), -) { - val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle() - val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle() - - TrackScreenViewEvent(screenName = "Topic: ${viewModel.topicId}") - TopicScreen( - topicUiState = topicUiState, - newsUiState = newsUiState, - modifier = modifier, - onBackClick = onBackClick, - onFollowClick = viewModel::followTopicToggle, - onBookmarkChanged = viewModel::bookmarkNews, - onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, - onTopicClick = onTopicClick, - ) -} - -@VisibleForTesting @Composable internal fun TopicScreen( topicUiState: TopicUiState, - newsUiState: NewsUiState, onBackClick: () -> Unit, - onFollowClick: (Boolean) -> Unit, + onFollowClick: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit, modifier: Modifier = Modifier, ) { - val state = rememberLazyListState() + val state = rememberLazyGridState() TrackScrollJank(scrollableState = state, stateName = "topic:screen") - LazyColumn( + + LazyVerticalGrid( + columns = GridCells.Adaptive(300.dp), state = state, - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .selectableGroup() + .testTag("interests:topics"), + contentPadding = PaddingValues(vertical = 16.dp), ) { - item { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) - } when (topicUiState) { Loading -> item { NiaLoadingWheel( @@ -114,19 +93,26 @@ internal fun TopicScreen( ) } - TopicUiState.Error -> TODO() - is TopicUiState.Success -> { + Error -> { item { + Text(text = stringResource(id = string.topic_error)) + } + } + + is Success -> { + item(span = { GridItemSpan(maxLineSpan) }) { TopicToolbar( onBackClick = onBackClick, - onFollowClick = onFollowClick, + onFollowClick = { isChecked -> + onFollowClick(topicUiState.followableTopic.topic.id, isChecked) + }, uiState = topicUiState.followableTopic, ) } - TopicBody( + topicBody( name = topicUiState.followableTopic.topic.name, description = topicUiState.followableTopic.topic.longDescription, - news = newsUiState, + news = topicUiState.newsResources, imageUrl = topicUiState.followableTopic.topic.imageUrl, onBookmarkChanged = onBookmarkChanged, onNewsResourceViewed = onNewsResourceViewed, @@ -134,27 +120,34 @@ internal fun TopicScreen( ) } } - item { + + item(span = { GridItemSpan(maxLineSpan) }) { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } } } -private fun LazyListScope.TopicBody( +private fun LazyGridScope.topicBody( name: String, description: String, - news: NewsUiState, + news: List, imageUrl: String, onBookmarkChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { - // TODO: Show icon if available - item { +// // TODO: Show icon if available + item(span = { GridItemSpan(maxLineSpan) }) { TopicHeader(name, description, imageUrl) } - - userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick) + newsFeed( + feedState = NewsFeedUiState.Success( + news, + ), + onNewsResourceViewed = onNewsResourceViewed, + onNewsResourcesCheckedChanged = onBookmarkChanged, + onTopicClick = onTopicClick, + ) } @Composable @@ -181,43 +174,15 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) { } } -// TODO: Could/should this be replaced with [LazyGridScope.newsFeed]? -private fun LazyListScope.userNewsResourceCards( - news: NewsUiState, - onBookmarkChanged: (String, Boolean) -> Unit, - onNewsResourceViewed: (String) -> Unit, - onTopicClick: (String) -> Unit, -) { - when (news) { - is NewsUiState.Success -> { - userNewsResourceCardItems( - items = news.news, - onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) }, - onNewsResourceViewed = onNewsResourceViewed, - onTopicClick = onTopicClick, - itemModifier = Modifier.padding(24.dp), - ) - } - - is NewsUiState.Loading -> item { - NiaLoadingWheel(contentDesc = "Loading news") // TODO - } - - else -> item { - Text("Error") // TODO - } - } -} - @Preview @Composable private fun TopicBodyPreview() { NiaTheme { - LazyColumn { - TopicBody( + LazyVerticalGrid(columns = GridCells.Fixed(2)) { + topicBody( name = "Jetpack Compose", description = "Lorem ipsum maximum", - news = NewsUiState.Success(emptyList()), + news = emptyList(), imageUrl = "", onBookmarkChanged = { _, _ -> }, onNewsResourceViewed = {}, @@ -273,10 +238,12 @@ fun TopicScreenPopulated( NiaTheme { NiaBackground { TopicScreen( - topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]), - newsUiState = NewsUiState.Success(userNewsResources), + topicUiState = Success( + followableTopic = userNewsResources[0].followableTopics[0], + newsResources = userNewsResources, + ), onBackClick = {}, - onFollowClick = {}, + onFollowClick = { _, _ -> }, onBookmarkChanged = { _, _ -> }, onNewsResourceViewed = {}, onTopicClick = {}, @@ -291,10 +258,9 @@ fun TopicScreenLoading() { NiaTheme { NiaBackground { TopicScreen( - topicUiState = TopicUiState.Loading, - newsUiState = NewsUiState.Loading, + topicUiState = Loading, onBackClick = {}, - onFollowClick = {}, + onFollowClick = { _, _ -> }, onBookmarkChanged = { _, _ -> }, onNewsResourceViewed = {}, onTopicClick = {}, diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TopicUiState.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TopicUiState.kt new file mode 100644 index 0000000000..282636df67 --- /dev/null +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TopicUiState.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 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.interests + +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource + +sealed interface TopicUiState { + data class Success( + val followableTopic: FollowableTopic, + val newsResources: List, + ) : TopicUiState + + object Error : TopicUiState + object Loading : TopicUiState +} diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt index bef6987f4e..612117e66f 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt @@ -16,31 +16,40 @@ package com.google.samples.apps.nowinandroid.feature.interests.navigation +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import androidx.navigation.navigation +import androidx.navigation.navArgument import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute -private const val interestsGraphRoutePattern = "interests_graph" -const val interestsRoute = "interests_route" +const val topicIdArg = "topicId" +const val interestsRoute = "interests_route?$topicIdArg={$topicIdArg}" -fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) { - this.navigate(interestsGraphRoutePattern, navOptions) +fun NavController.navigateToInterests( + selectedTopicId: String? = null, + navOptions: NavOptions? = null, +) { + if (selectedTopicId != null) { + navigate("interests_route?$topicIdArg=$selectedTopicId", navOptions) + } else { + navigate("interests_route", navOptions) + } } fun NavGraphBuilder.interestsGraph( + listState: LazyGridState, + shouldShowTwoPane: Boolean, onTopicClick: (String) -> Unit, - nestedGraphs: NavGraphBuilder.() -> Unit, + onBackClick: () -> Unit, ) { - navigation( - route = interestsGraphRoutePattern, - startDestination = interestsRoute, + composable( + route = interestsRoute, + arguments = listOf( + navArgument(topicIdArg) { nullable = true }, + ), ) { - composable(route = interestsRoute) { - InterestsRoute(onTopicClick) - } - nestedGraphs() + InterestsRoute(listState, shouldShowTwoPane, onTopicClick, onBackClick) } } diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/src/main/res/values/strings.xml index 68deb933ea..bfdfac914e 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/src/main/res/values/strings.xml @@ -23,4 +23,6 @@ Interests Menu Search + Loading topic + Error loading topic diff --git a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt index c46cb7780b..56851655a4 100644 --- a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt +++ b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt @@ -16,22 +16,31 @@ package com.google.samples.apps.nowinandroid.interests +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -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.data.followableTopicTestData +import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData +import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.interests.TopicUiState +import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertIs /** * To learn more about how this test handles Flows created with stateIn, see @@ -43,39 +52,48 @@ class InterestsViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val userDataRepository = TestUserDataRepository() + private val newsRepository = TestNewsRepository() + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( + newsRepository = newsRepository, + userDataRepository = userDataRepository, + ) private val topicsRepository = TestTopicsRepository() private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( topicsRepository = topicsRepository, userDataRepository = userDataRepository, ) + private val selectedTopidId: String = testInputTopics[0].topic.id private lateinit var viewModel: InterestsViewModel @Before fun setup() { viewModel = InterestsViewModel( + savedStateHandle = SavedStateHandle(mapOf(topicIdArg to selectedTopidId)), userDataRepository = userDataRepository, getFollowableTopics = getFollowableTopicsUseCase, + topicsRepository = topicsRepository, + userNewsResourceRepository = userNewsResourceRepository, ) } @Test fun uiState_whenInitialized_thenShowLoading() = runTest { - assertEquals(InterestsUiState.Loading, viewModel.uiState.value) + assertEquals(InterestsUiState.Loading, viewModel.interestUiState.value) } @Test fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.interestUiState.collect() } userDataRepository.setFollowedTopicIds(emptySet()) - assertEquals(InterestsUiState.Loading, viewModel.uiState.value) + assertEquals(InterestsUiState.Loading, viewModel.interestUiState.value) collectJob.cancel() } @Test fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.interestUiState.collect() } val toggleTopicId = testOutputTopics[1].topic.id topicsRepository.sendTopics(testInputTopics.map { it.topic }) @@ -83,7 +101,7 @@ class InterestsViewModelTest { assertEquals( false, - (viewModel.uiState.value as InterestsUiState.Interests) + (viewModel.interestUiState.value as InterestsUiState.Interests) .topics.first { it.topic.id == toggleTopicId }.isFollowed, ) @@ -93,8 +111,8 @@ class InterestsViewModelTest { ) assertEquals( - InterestsUiState.Interests(topics = testOutputTopics), - viewModel.uiState.value, + InterestsUiState.Interests(topics = testOutputTopics, selectedTopicId = selectedTopidId), + viewModel.interestUiState.value, ) collectJob.cancel() @@ -102,7 +120,7 @@ class InterestsViewModelTest { @Test fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.interestUiState.collect() } val toggleTopicId = testOutputTopics[1].topic.id @@ -113,7 +131,7 @@ class InterestsViewModelTest { assertEquals( true, - (viewModel.uiState.value as InterestsUiState.Interests) + (viewModel.interestUiState.value as InterestsUiState.Interests) .topics.first { it.topic.id == toggleTopicId }.isFollowed, ) @@ -123,90 +141,44 @@ class InterestsViewModelTest { ) assertEquals( - InterestsUiState.Interests(topics = testInputTopics), - viewModel.uiState.value, + InterestsUiState.Interests(topics = testInputTopics, selectedTopicId = selectedTopidId), + viewModel.interestUiState.value, ) collectJob.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_SHORT_DESC = "At vero eos et accusamus." -private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus." -private const val TOPIC_URL = "URL" -private const val TOPIC_IMAGE_URL = "Image URL" - -private val testInputTopics = listOf( - FollowableTopic( - Topic( - id = "0", - name = TOPIC_1_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = true, - ), - FollowableTopic( - Topic( - id = "1", - name = TOPIC_2_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = false, - ), - FollowableTopic( - Topic( - id = "2", - name = TOPIC_3_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = false, - ), -) - -private val testOutputTopics = listOf( - FollowableTopic( - Topic( - id = "0", - name = TOPIC_1_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = true, - ), - FollowableTopic( - Topic( - id = "1", - name = TOPIC_2_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = true, - ), - FollowableTopic( - Topic( - id = "2", - name = TOPIC_3_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = false, - ), -) + @Test + fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest { + topicsRepository.sendTopics(testInputTopics.map { it.topic }) + userDataRepository.setFollowedTopicIds(setOf(followableTopicTestData[1].topic.id)) + newsRepository.sendNewsResources(newsResourcesTestData) + + runBlocking(UnconfinedTestDispatcher()) { + viewModel.topicUiState.test { + assertEquals(null, awaitItem()) + assertIs(awaitItem()) + assertIs(awaitItem()) + + val item = viewModel.topicUiState.value + assertIs(item) + + val topicFromRepository = topicsRepository.getTopic( + testInputTopics[0].topic.id, + ).first() + + assertEquals(topicFromRepository, item.followableTopic.topic) + } + } + } + + @Test + fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } + + userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) + assertEquals(TopicUiState.Loading, viewModel.topicUiState.value) + + collectJob.cancel() + } +} diff --git a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/TopicsTestData.kt b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/TopicsTestData.kt new file mode 100644 index 0000000000..3f2ac447a8 --- /dev/null +++ b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/TopicsTestData.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2023 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.interests + +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.Topic + +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_SHORT_DESC = "At vero eos et accusamus." +private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus." +private const val TOPIC_URL = "URL" +private const val TOPIC_IMAGE_URL = "Image URL" + +internal val testInputTopics = listOf( + FollowableTopic( + Topic( + id = "0", + name = TOPIC_1_NAME, + shortDescription = TOPIC_SHORT_DESC, + longDescription = TOPIC_LONG_DESC, + url = TOPIC_URL, + imageUrl = TOPIC_IMAGE_URL, + ), + isFollowed = true, + ), + FollowableTopic( + Topic( + id = "1", + name = TOPIC_2_NAME, + shortDescription = TOPIC_SHORT_DESC, + longDescription = TOPIC_LONG_DESC, + url = TOPIC_URL, + imageUrl = TOPIC_IMAGE_URL, + ), + isFollowed = false, + ), + FollowableTopic( + Topic( + id = "2", + name = TOPIC_3_NAME, + shortDescription = TOPIC_SHORT_DESC, + longDescription = TOPIC_LONG_DESC, + url = TOPIC_URL, + imageUrl = TOPIC_IMAGE_URL, + ), + isFollowed = false, + ), +) + +internal val testOutputTopics = listOf( + FollowableTopic( + Topic( + id = "0", + name = TOPIC_1_NAME, + shortDescription = TOPIC_SHORT_DESC, + longDescription = TOPIC_LONG_DESC, + url = TOPIC_URL, + imageUrl = TOPIC_IMAGE_URL, + ), + isFollowed = true, + ), + FollowableTopic( + Topic( + id = "1", + name = TOPIC_2_NAME, + shortDescription = TOPIC_SHORT_DESC, + longDescription = TOPIC_LONG_DESC, + url = TOPIC_URL, + imageUrl = TOPIC_IMAGE_URL, + ), + isFollowed = true, + ), + FollowableTopic( + Topic( + id = "2", + name = TOPIC_3_NAME, + shortDescription = TOPIC_SHORT_DESC, + longDescription = TOPIC_LONG_DESC, + url = TOPIC_URL, + imageUrl = TOPIC_IMAGE_URL, + ), + isFollowed = false, + ), +) diff --git a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt index 53f00c0dc5..ace88dd652 100644 --- a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt +++ b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -17,14 +17,15 @@ package com.google.samples.apps.nowinandroid.feature.search import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToIndex import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID @@ -139,22 +140,18 @@ class SearchScreenTest { composeTestRule .onNodeWithText(topicsString) .assertIsDisplayed() - composeTestRule - .onNodeWithText(followableTopicTestData[0].topic.name) - .assertIsDisplayed() - composeTestRule - .onNodeWithText(followableTopicTestData[1].topic.name) - .assertIsDisplayed() - composeTestRule - .onNodeWithText(followableTopicTestData[2].topic.name) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithContentDescription(followButtonContentDesc) - .assertCountEquals(2) - composeTestRule - .onAllNodesWithContentDescription(unfollowButtonContentDesc) - .assertCountEquals(1) + val scrollableNode = composeTestRule + .onAllNodes(hasScrollToNodeAction()) + .onFirst() + + followableTopicTestData.forEachIndexed { index, followableTopic -> + scrollableNode.performScrollToIndex(index) + + composeTestRule + .onNodeWithText(followableTopic.topic.name) + .assertIsDisplayed() + } } @Test diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index cb48bffe38..834c916c74 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -324,6 +324,7 @@ private fun SearchResultBody( }, ) { InterestsItem( + isSelected = false, name = followableTopic.topic.name, following = followableTopic.isFollowed, description = followableTopic.topic.shortDescription, diff --git a/feature/topic/.gitignore b/feature/topic/.gitignore deleted file mode 100644 index 42afabfd2a..0000000000 --- a/feature/topic/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/topic/README.md b/feature/topic/README.md deleted file mode 100644 index d74517e63f..0000000000 --- a/feature/topic/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# :feature:topic module - -![Dependency graph](../../docs/images/graphs/dep_graph_feature_topic.png) diff --git a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt deleted file mode 100644 index 94f86a8e40..0000000000 --- a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -/* - * 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.topic - -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.hasScrollToNodeAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onFirst -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performScrollToNode -import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData -import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -/** - * UI test for checking the correct behaviour of the Topic screen; - * Verifies that, when a specific UiState is set, the corresponding - * composables and details are shown - */ -class TopicScreenTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - private lateinit var topicLoading: String - - @Before - fun setup() { - composeTestRule.activity.apply { - topicLoading = getString(R.string.topic_loading) - } - } - - @Test - fun niaLoadingWheel_whenScreenIsLoading_showLoading() { - composeTestRule.setContent { - TopicScreen( - topicUiState = TopicUiState.Loading, - newsUiState = NewsUiState.Loading, - onBackClick = {}, - onFollowClick = {}, - onTopicClick = {}, - onBookmarkChanged = { _, _ -> }, - onNewsResourceViewed = {}, - ) - } - - composeTestRule - .onNodeWithContentDescription(topicLoading) - .assertExists() - } - - @Test - fun topicTitle_whenTopicIsSuccess_isShown() { - val testTopic = followableTopicTestData.first() - composeTestRule.setContent { - TopicScreen( - topicUiState = TopicUiState.Success(testTopic), - newsUiState = NewsUiState.Loading, - onBackClick = {}, - onFollowClick = {}, - onTopicClick = {}, - onBookmarkChanged = { _, _ -> }, - onNewsResourceViewed = {}, - ) - } - - // Name is shown - composeTestRule - .onNodeWithText(testTopic.topic.name) - .assertExists() - - // Description is shown - composeTestRule - .onNodeWithText(testTopic.topic.longDescription) - .assertExists() - } - - @Test - fun news_whenTopicIsLoading_isNotShown() { - composeTestRule.setContent { - TopicScreen( - topicUiState = TopicUiState.Loading, - newsUiState = NewsUiState.Success(userNewsResourcesTestData), - onBackClick = {}, - onFollowClick = {}, - onTopicClick = {}, - onBookmarkChanged = { _, _ -> }, - onNewsResourceViewed = {}, - ) - } - - // Loading indicator shown - composeTestRule - .onNodeWithContentDescription(topicLoading) - .assertExists() - } - - @Test - fun news_whenSuccessAndTopicIsSuccess_isShown() { - val testTopic = followableTopicTestData.first() - composeTestRule.setContent { - TopicScreen( - topicUiState = TopicUiState.Success(testTopic), - newsUiState = NewsUiState.Success( - userNewsResourcesTestData, - ), - onBackClick = {}, - onFollowClick = {}, - onTopicClick = {}, - onBookmarkChanged = { _, _ -> }, - onNewsResourceViewed = {}, - ) - } - - // Scroll to first news title if available - composeTestRule - .onAllNodes(hasScrollToNodeAction()) - .onFirst() - .performScrollToNode(hasText(userNewsResourcesTestData.first().title)) - } -} diff --git a/feature/topic/src/main/AndroidManifest.xml b/feature/topic/src/main/AndroidManifest.xml deleted file mode 100644 index 547d480541..0000000000 --- a/feature/topic/src/main/AndroidManifest.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt deleted file mode 100644 index 2b2565f9e8..0000000000 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - * 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.feature.topic - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository -import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder -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.model.data.UserNewsResource -import com.google.samples.apps.nowinandroid.core.result.Result -import com.google.samples.apps.nowinandroid.core.result.asResult -import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class TopicViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - stringDecoder: StringDecoder, - private val userDataRepository: UserDataRepository, - topicsRepository: TopicsRepository, - userNewsResourceRepository: UserNewsResourceRepository, -) : ViewModel() { - - private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) - - val topicId = topicArgs.topicId - - val topicUiState: StateFlow = topicUiState( - topicId = topicArgs.topicId, - userDataRepository = userDataRepository, - topicsRepository = topicsRepository, - ) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = TopicUiState.Loading, - ) - - val newUiState: StateFlow = newsUiState( - topicId = topicArgs.topicId, - userDataRepository = userDataRepository, - userNewsResourceRepository = userNewsResourceRepository, - ) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = NewsUiState.Loading, - ) - - fun followTopicToggle(followed: Boolean) { - viewModelScope.launch { - userDataRepository.toggleFollowedTopicId(topicArgs.topicId, followed) - } - } - - fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) { - viewModelScope.launch { - userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) - } - } - - fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { - viewModelScope.launch { - userDataRepository.setNewsResourceViewed(newsResourceId, viewed) - } - } -} - -private fun topicUiState( - topicId: String, - userDataRepository: UserDataRepository, - topicsRepository: TopicsRepository, -): Flow { - // Observe the followed topics, as they could change over time. - val followedTopicIds: Flow> = - userDataRepository.userData - .map { it.followedTopics } - - // Observe topic information - val topicStream: Flow = topicsRepository.getTopic( - id = topicId, - ) - - return combine( - followedTopicIds, - topicStream, - ::Pair, - ) - .asResult() - .map { followedTopicToTopicResult -> - when (followedTopicToTopicResult) { - is Result.Success -> { - val (followedTopics, topic) = followedTopicToTopicResult.data - val followed = followedTopics.contains(topicId) - TopicUiState.Success( - followableTopic = FollowableTopic( - topic = topic, - isFollowed = followed, - ), - ) - } - - is Result.Loading -> { - TopicUiState.Loading - } - - is Result.Error -> { - TopicUiState.Error - } - } - } -} - -private fun newsUiState( - topicId: String, - userNewsResourceRepository: UserNewsResourceRepository, - userDataRepository: UserDataRepository, -): Flow { - // Observe news - val newsStream: Flow> = userNewsResourceRepository.observeAll( - NewsResourceQuery(filterTopicIds = setOf(element = topicId)), - ) - - // Observe bookmarks - val bookmark: Flow> = userDataRepository.userData - .map { it.bookmarkedNewsResources } - - return combine( - newsStream, - bookmark, - ::Pair, - ) - .asResult() - .map { newsToBookmarksResult -> - when (newsToBookmarksResult) { - is Result.Success -> { - val news = newsToBookmarksResult.data.first - NewsUiState.Success(news) - } - - is Result.Loading -> { - NewsUiState.Loading - } - - is Result.Error -> { - NewsUiState.Error - } - } - } -} - -sealed interface TopicUiState { - data class Success(val followableTopic: FollowableTopic) : TopicUiState - object Error : TopicUiState - object Loading : TopicUiState -} - -sealed interface NewsUiState { - data class Success(val news: List) : NewsUiState - object Error : NewsUiState - object Loading : NewsUiState -} diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt deleted file mode 100644 index 0954a52ac3..0000000000 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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.topic.navigation - -import android.net.Uri -import androidx.annotation.VisibleForTesting -import androidx.lifecycle.SavedStateHandle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavType -import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder -import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute - -@VisibleForTesting -internal const val topicIdArg = "topicId" - -internal class TopicArgs(val topicId: String) { - constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) : - this(stringDecoder.decodeString(checkNotNull(savedStateHandle[topicIdArg]))) -} - -fun NavController.navigateToTopic(topicId: String) { - val encodedId = Uri.encode(topicId) - this.navigate("topic_route/$encodedId") { - launchSingleTop = true - } -} - -fun NavGraphBuilder.topicScreen( - onBackClick: () -> Unit, - onTopicClick: (String) -> Unit, -) { - composable( - route = "topic_route/{$topicIdArg}", - arguments = listOf( - navArgument(topicIdArg) { type = NavType.StringType }, - ), - ) { - TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick) - } -} diff --git a/feature/topic/src/main/res/values/strings.xml b/feature/topic/src/main/res/values/strings.xml deleted file mode 100644 index 21e3ec246d..0000000000 --- a/feature/topic/src/main/res/values/strings.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - Topic - Loading topic - diff --git a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt deleted file mode 100644 index ff7a88160b..0000000000 --- a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ /dev/null @@ -1,275 +0,0 @@ -/* - * 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.topic - -import androidx.lifecycle.SavedStateHandle -import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository -import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic -import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video -import com.google.samples.apps.nowinandroid.core.model.data.Topic -import com.google.samples.apps.nowinandroid.core.testing.decoder.FakeStringDecoder -import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository -import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository -import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository -import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule -import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicIdArg -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Instant -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -/** - * To learn more about how this test handles Flows created with stateIn, see - * https://developer.android.com/kotlin/flow/test#statein - */ -class TopicViewModelTest { - - @get:Rule - val dispatcherRule = MainDispatcherRule() - - private val userDataRepository = TestUserDataRepository() - private val topicsRepository = TestTopicsRepository() - private val newsRepository = TestNewsRepository() - private val userNewsResourceRepository = CompositeUserNewsResourceRepository( - newsRepository = newsRepository, - userDataRepository = userDataRepository, - ) - private lateinit var viewModel: TopicViewModel - - @Before - fun setup() { - viewModel = TopicViewModel( - savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)), - stringDecoder = FakeStringDecoder(), - userDataRepository = userDataRepository, - topicsRepository = topicsRepository, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - - @Test - fun topicId_matchesTopicIdFromSavedStateHandle() = - assertEquals(testInputTopics[0].topic.id, viewModel.topicId) - - @Test - fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } - - topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic)) - userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) - val item = viewModel.topicUiState.value - assertIs(item) - - val topicFromRepository = topicsRepository.getTopic( - testInputTopics[0].topic.id, - ).first() - - assertEquals(topicFromRepository, item.followableTopic.topic) - - collectJob.cancel() - } - - @Test - fun uiStateNews_whenInitialized_thenShowLoading() = runTest { - assertEquals(NewsUiState.Loading, viewModel.newUiState.value) - } - - @Test - fun uiStateTopic_whenInitialized_thenShowLoading() = runTest { - assertEquals(TopicUiState.Loading, viewModel.topicUiState.value) - } - - @Test - fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } - - userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) - assertEquals(TopicUiState.Loading, viewModel.topicUiState.value) - - collectJob.cancel() - } - - @Test - fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() = - runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } - - topicsRepository.sendTopics(testInputTopics.map { it.topic }) - userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) - val topicUiState = viewModel.topicUiState.value - val newsUiState = viewModel.newUiState.value - - assertIs(topicUiState) - assertIs(newsUiState) - - collectJob.cancel() - } - - @Test - fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() = - runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { - combine( - viewModel.topicUiState, - viewModel.newUiState, - ::Pair, - ).collect() - } - topicsRepository.sendTopics(testInputTopics.map { it.topic }) - userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) - newsRepository.sendNewsResources(sampleNewsResources) - val topicUiState = viewModel.topicUiState.value - val newsUiState = viewModel.newUiState.value - - assertIs(topicUiState) - assertIs(newsUiState) - - collectJob.cancel() - } - - @Test - fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } - - topicsRepository.sendTopics(testInputTopics.map { it.topic }) - // Set which topic IDs are followed, not including 0. - userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) - - viewModel.followTopicToggle(true) - - assertEquals( - TopicUiState.Success(followableTopic = testOutputTopics[0]), - viewModel.topicUiState.value, - ) - - collectJob.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_SHORT_DESC = "At vero eos et accusamus." -private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus." -private const val TOPIC_URL = "URL" -private const val TOPIC_IMAGE_URL = "Image URL" - -private val testInputTopics = listOf( - FollowableTopic( - Topic( - id = "0", - name = TOPIC_1_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = true, - ), - FollowableTopic( - Topic( - id = "1", - name = TOPIC_2_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = false, - ), - FollowableTopic( - Topic( - id = "2", - name = TOPIC_3_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = false, - ), -) - -private val testOutputTopics = listOf( - FollowableTopic( - Topic( - id = "0", - name = TOPIC_1_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = true, - ), - FollowableTopic( - Topic( - id = "1", - name = TOPIC_2_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = true, - ), - FollowableTopic( - Topic( - id = "2", - name = TOPIC_3_NAME, - shortDescription = TOPIC_SHORT_DESC, - longDescription = TOPIC_LONG_DESC, - url = TOPIC_URL, - imageUrl = TOPIC_IMAGE_URL, - ), - isFollowed = false, - ), -) - -private val sampleNewsResources = listOf( - NewsResource( - id = "1", - title = "Thanks for helping us reach 1M YouTube Subscribers", - content = "Thank you everyone for following the Now in Android series and everything the " + - "Android Developers YouTube channel has to offer. During the Android Developer " + - "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + - "thank you all.", - url = "https://youtu.be/-fJ6poHQrjM", - headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), - type = Video, - topics = listOf( - Topic( - id = "0", - name = "Headlines", - shortDescription = "", - longDescription = "long description", - url = "URL", - imageUrl = "image URL", - ), - ), - ), -) diff --git a/settings.gradle.kts b/settings.gradle.kts index d0c477b3d0..c0a07fa7c0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,7 +52,6 @@ include(":core:notifications") include(":feature:foryou") include(":feature:interests") include(":feature:bookmarks") -include(":feature:topic") include(":feature:search") include(":feature:settings") include(":lint")