From cce9402b04ffa65f12e32c1a82061c172e598bea Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Sat, 30 Apr 2022 12:54:03 -0700 Subject: [PATCH] Add multi-column support for cards on the for you feed Fixes: 228074722 Change-Id: Id077b4dd93452b5f9399f94161c9b66594436975 --- .../samples/apps/nowinandroid/ui/NiaApp.kt | 1 + .../apps/nowinandroid/ui/NiaNavGraph.kt | 4 +- feature-foryou/build.gradle.kts | 1 + .../feature/foryou/ForYouScreenTest.kt | 731 ++++++++++++------ .../feature/foryou/ForYouScreen.kt | 532 ++++++++----- kokoro/build.sh | 2 +- 6 files changed, 815 insertions(+), 456 deletions(-) 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 be8f0d614..c05427873 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 @@ -107,6 +107,7 @@ fun NiaApp(windowSizeClass: WindowSizeClass) { } NiaNavGraph( + windowSizeClass = windowSizeClass, navController = navController, modifier = Modifier .padding(padding) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt index f721fa2e4..927ba49d4 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.ui import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController @@ -42,6 +43,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute */ @Composable fun NiaNavGraph( + windowSizeClass: WindowSizeClass, modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), startDestination: String = NiaDestinations.FOR_YOU_ROUTE @@ -52,7 +54,7 @@ fun NiaNavGraph( modifier = modifier, ) { composable(NiaDestinations.FOR_YOU_ROUTE) { - ForYouRoute() + ForYouRoute(windowSizeClass) } composable(NiaDestinations.EPISODES_ROUTE) { Text("EPISODES") diff --git a/feature-foryou/build.gradle.kts b/feature-foryou/build.gradle.kts index e9eec2610..5fe5db4ab 100644 --- a/feature-foryou/build.gradle.kts +++ b/feature-foryou/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) + implementation(libs.androidx.compose.material3.windowSizeClass) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.lifecycle.viewModelCompose) diff --git a/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index 73d400663..a2e52bdb4 100644 --- a/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature-foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -17,6 +17,11 @@ package com.google.samples.apps.nowinandroid.feature.foryou import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Companion import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled @@ -30,13 +35,20 @@ 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 androidx.compose.ui.unit.DpSize import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video +import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.Topic +import kotlinx.datetime.Instant +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) class ForYouScreenTest { @get:Rule val composeTestRule = createAndroidComposeRule() @@ -50,14 +62,19 @@ class ForYouScreenTest { @Test fun circularProgressIndicator_whenScreenIsLoading_exists() { composeTestRule.setContent { - ForYouScreen( - interestsSelectionState = ForYouInterestsSelectionState.Loading, - feedState = ForYouFeedState.Loading, - onAuthorCheckedChanged = { _, _ -> }, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> } - ) + BoxWithConstraints { + ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight) + ), + interestsSelectionState = ForYouInterestsSelectionState.Loading, + feedState = ForYouFeedState.Loading, + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } + ) + } } composeTestRule @@ -70,76 +87,81 @@ class ForYouScreenTest { @Test fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() { composeTestRule.setContent { - ForYouScreen( - interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( - topics = listOf( - FollowableTopic( - topic = Topic( - id = "0", - name = "Headlines", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + BoxWithConstraints { + ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight) + ), + interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( + topics = listOf( + FollowableTopic( + topic = Topic( + id = "0", + name = "Headlines", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "1", - name = "UI", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "1", + name = "UI", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "2", - name = "Tools", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "2", + name = "Tools", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false ), - ), - authors = listOf( - FollowableAuthor( - author = Author( - id = "0", - name = "Android Dev", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + authors = listOf( + FollowableAuthor( + author = Author( + id = "0", + name = "Android Dev", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false ), - isFollowed = false - ), - FollowableAuthor( - author = Author( - id = "1", - name = "Android Dev 2", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + FollowableAuthor( + author = Author( + id = "1", + name = "Android Dev 2", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false ), - isFollowed = false - ), - ) - ), - feedState = ForYouFeedState.Success( - feed = emptyList() - ), - onAuthorCheckedChanged = { _, _ -> }, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> } - ) + ) + ), + feedState = ForYouFeedState.Success( + feed = emptyList() + ), + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } + ) + } } composeTestRule @@ -173,76 +195,81 @@ class ForYouScreenTest { @Test fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() { composeTestRule.setContent { - ForYouScreen( - interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( - topics = listOf( - FollowableTopic( - topic = Topic( - id = "0", - name = "Headlines", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + BoxWithConstraints { + ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight) + ), + interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( + topics = listOf( + FollowableTopic( + topic = Topic( + id = "0", + name = "Headlines", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "1", - name = "UI", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "1", + name = "UI", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = true ), - isFollowed = true - ), - FollowableTopic( - topic = Topic( - id = "2", - name = "Tools", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "2", + name = "Tools", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false ), - ), - authors = listOf( - FollowableAuthor( - author = Author( - id = "0", - name = "Android Dev", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + authors = listOf( + FollowableAuthor( + author = Author( + id = "0", + name = "Android Dev", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false ), - isFollowed = false - ), - FollowableAuthor( - author = Author( - id = "1", - name = "Android Dev 2", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + FollowableAuthor( + author = Author( + id = "1", + name = "Android Dev 2", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false ), - isFollowed = false ), ), - ), - feedState = ForYouFeedState.Success( - feed = emptyList() - ), - onAuthorCheckedChanged = { _, _ -> }, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> } - ) + feedState = ForYouFeedState.Success( + feed = emptyList() + ), + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } + ) + } } composeTestRule @@ -282,76 +309,81 @@ class ForYouScreenTest { @Test fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() { composeTestRule.setContent { - ForYouScreen( - interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( - topics = listOf( - FollowableTopic( - topic = Topic( - id = "0", - name = "Headlines", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + BoxWithConstraints { + ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight) + ), + interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( + topics = listOf( + FollowableTopic( + topic = Topic( + id = "0", + name = "Headlines", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "1", - name = "UI", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "1", + name = "UI", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = true ), - isFollowed = true - ), - FollowableTopic( - topic = Topic( - id = "2", - name = "Tools", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "2", + name = "Tools", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false ), - ), - authors = listOf( - FollowableAuthor( - author = Author( - id = "0", - name = "Android Dev", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + authors = listOf( + FollowableAuthor( + author = Author( + id = "0", + name = "Android Dev", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false ), - isFollowed = false - ), - FollowableAuthor( - author = Author( - id = "1", - name = "Android Dev 2", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + FollowableAuthor( + author = Author( + id = "1", + name = "Android Dev 2", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false ), - isFollowed = false ), ), - ), - feedState = ForYouFeedState.Success( - feed = emptyList() - ), - onAuthorCheckedChanged = { _, _ -> }, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> } - ) + feedState = ForYouFeedState.Success( + feed = emptyList() + ), + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } + ) + } } composeTestRule @@ -391,74 +423,114 @@ class ForYouScreenTest { @Test fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() { composeTestRule.setContent { - ForYouScreen( - interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( - topics = listOf( - FollowableTopic( - topic = Topic( - id = "0", - name = "Headlines", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + BoxWithConstraints { + ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight) + ), + interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( + topics = listOf( + FollowableTopic( + topic = Topic( + id = "0", + name = "Headlines", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "1", - name = "UI", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "1", + name = "UI", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "2", - name = "Tools", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" + FollowableTopic( + topic = Topic( + id = "2", + name = "Tools", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false ), - isFollowed = false ), - ), - authors = listOf( - FollowableAuthor( - author = Author( - id = "0", - name = "Android Dev", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + authors = listOf( + FollowableAuthor( + author = Author( + id = "0", + name = "Android Dev", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = true ), - isFollowed = true - ), - FollowableAuthor( - author = Author( - id = "1", - name = "Android Dev 2", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + FollowableAuthor( + author = Author( + id = "1", + name = "Android Dev 2", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false ), - isFollowed = false ), ), - ), - feedState = ForYouFeedState.Loading, - onAuthorCheckedChanged = { _, _ -> }, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> } + feedState = ForYouFeedState.Loading, + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } + ) + } + } + + // Scroll until the loading indicator is visible + composeTestRule + .onAllNodes(hasScrollToNodeAction()) + .onFirst() + .performScrollToNode( + hasContentDescription( + composeTestRule.activity.resources.getString(R.string.for_you_loading) + ) + ) + + composeTestRule + .onNodeWithContentDescription( + composeTestRule.activity.resources.getString(R.string.for_you_loading) ) + .assertExists() + } + + @Test + fun feed_whenNoInterestsSelectionAndLoading_showsLoadingIndicator() { + composeTestRule.setContent { + BoxWithConstraints { + ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight) + ), + interestsSelectionState = ForYouInterestsSelectionState.NoInterestsSelection, + feedState = ForYouFeedState.Loading, + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } + ) + } } // Scroll until the loading indicator is visible @@ -477,4 +549,153 @@ class ForYouScreenTest { ) .assertExists() } + + @Test + fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() { + lateinit var windowSizeClass: WindowSizeClass + + val saveableNewsResources = listOf( + SaveableNewsResource( + newsResource = NewsResource( + id = "1", + episodeId = "52", + 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 = "", + url = "", + imageUrl = "" + ) + ), + authors = emptyList() + ), + isSaved = false + ), + SaveableNewsResource( + newsResource = NewsResource( + id = "2", + episodeId = "52", + title = "Transformations and customisations in the Paging Library", + content = "A demonstration of different operations that can be performed " + + "with Paging. Transformations like inserting separators, when to " + + "create a new pager, and customisation options for consuming " + + "PagingData.", + url = "https://youtu.be/ZARz0pjm5YM", + headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), + type = Video, + topics = listOf( + Topic( + id = "1", + name = "UI", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + ), + authors = emptyList() + ), + isSaved = false + ), + SaveableNewsResource( + newsResource = NewsResource( + id = "3", + episodeId = "52", + title = "Community tip on Paging", + content = "Tips for using the Paging library from the developer community", + url = "https://youtu.be/r5JgIyS3t3s", + headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-08T00:00:00.000Z"), + type = Video, + topics = listOf( + Topic( + id = "1", + name = "UI", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + ), + authors = emptyList() + ), + isSaved = false + ), + ) + + composeTestRule.setContent { + BoxWithConstraints { + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight) + ) + + ForYouScreen( + windowSizeClass = windowSizeClass, + interestsSelectionState = ForYouInterestsSelectionState.NoInterestsSelection, + feedState = ForYouFeedState.Success( + feed = saveableNewsResources + ), + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } + ) + } + } + + // Scroll until the second feed item is visible + // This will cause both the first and second feed items to be visible at the same time, + // so we can compare their positions to each other. + composeTestRule + .onAllNodes(hasScrollToNodeAction()) + .onFirst() + .performScrollToNode( + hasText( + "Transformations and customisations in the Paging Library", + substring = true + ) + ) + + val firstFeedItem = composeTestRule + .onNodeWithText("Thanks for helping us reach 1M YouTube Subscribers", substring = true) + .assertHasClickAction() + .fetchSemanticsNode() + + val secondFeedItem = composeTestRule + .onNodeWithText( + "Transformations and customisations in the Paging Library", + substring = true + ) + .assertHasClickAction() + .fetchSemanticsNode() + + when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact, Companion.Medium -> { + // On smaller screen widths, the second feed item should be below the first because + // they are displayed in a single column + assertTrue( + firstFeedItem.positionInRoot.y < secondFeedItem.positionInRoot.y + ) + } + else -> { + // On larger screen widths, the second feed item should be inline with the first + // because they are displayed in more than one column + assertTrue( + firstFeedItem.positionInRoot.y == secondFeedItem.positionInRoot.y + ) + } + } + } } diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index c80a792cb..9abf130d1 100644 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -16,7 +16,12 @@ package com.google.samples.apps.nowinandroid.feature.foryou +import android.content.Intent +import android.net.Uri +import androidx.annotation.IntRange import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,9 +38,11 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells.Fixed +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -47,19 +54,25 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import com.google.samples.apps.nowinandroid.core.model.data.Author @@ -70,21 +83,25 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel +import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded import com.google.samples.apps.nowinandroid.core.ui.component.NiaToggleButton import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons -import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems +import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTypography +import kotlin.math.floor import kotlinx.datetime.Instant @Composable fun ForYouRoute( + windowSizeClass: WindowSizeClass, modifier: Modifier = Modifier, viewModel: ForYouViewModel = hiltViewModel() ) { val interestsSelectionState by viewModel.interestsSelectionState.collectAsState() val feedState by viewModel.feedState.collectAsState() ForYouScreen( + windowSizeClass = windowSizeClass, modifier = modifier, interestsSelectionState = interestsSelectionState, feedState = feedState, @@ -97,6 +114,7 @@ fun ForYouRoute( @Composable fun ForYouScreen( + windowSizeClass: WindowSizeClass, interestsSelectionState: ForYouInterestsSelectionState, feedState: ForYouFeedState, onTopicCheckedChanged: (String, Boolean) -> Unit, @@ -105,35 +123,91 @@ fun ForYouScreen( onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn( - modifier = modifier.fillMaxSize() + // TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed: + // https://issuetracker.google.com/issues/230514914 + // https://issuetracker.google.com/issues/231320714 + BoxWithConstraints( + modifier = modifier ) { - item { - Spacer( - // TODO: Replace with windowInsetsTopHeight after - // https://issuetracker.google.com/issues/230383055 - Modifier.windowInsetsPadding( - WindowInsets.safeDrawing.only(WindowInsetsSides.Top) - ) - ) + val numberOfColumns = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> 1 + else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1) } - item { - NiaTopAppBar( - titleRes = R.string.top_app_bar_title, - navigationIcon = Icons.Filled.Search, - navigationIconContentDescription = stringResource( - id = R.string.top_app_bar_navigation_button_content_desc - ), - actionIcon = Icons.Outlined.AccountCircle, - actionIconContentDescription = stringResource( - id = R.string.top_app_bar_navigation_button_content_desc + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Spacer( + // TODO: Replace with windowInsetsTopHeight after + // https://issuetracker.google.com/issues/230383055 + Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Top) + ) + ) + } + + item { + NiaTopAppBar( + titleRes = R.string.top_app_bar_title, + navigationIcon = Icons.Filled.Search, + navigationIconContentDescription = stringResource( + id = R.string.top_app_bar_navigation_button_content_desc + ), + actionIcon = Icons.Outlined.AccountCircle, + actionIconContentDescription = stringResource( + id = R.string.top_app_bar_navigation_button_content_desc + ), ) + } + + InterestsSelection( + interestsSelectionState = interestsSelectionState, + showLoadingUIIfLoading = true, + onAuthorCheckedChanged = onAuthorCheckedChanged, + onTopicCheckedChanged = onTopicCheckedChanged, + saveFollowedTopics = saveFollowedTopics + ) + + Feed( + feedState = feedState, + // Avoid showing a second loading wheel if we already are for the interests + // selection + showLoadingUIIfLoading = + interestsSelectionState !is ForYouInterestsSelectionState.Loading, + numberOfColumns = numberOfColumns, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged ) + + item { + Spacer( + // TODO: Replace with windowInsetsBottomHeight after + // https://issuetracker.google.com/issues/230383055 + Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom) + ) + ) + } } + } +} - when (interestsSelectionState) { - ForYouInterestsSelectionState.Loading -> { +/** + * An extension on [LazyListScope] defining the interests selection portion of the for you screen. + * Depending on the [interestsSelectionState], this might emit no items. + * + * @param showLoadingUIIfLoading if true, show a visual indication of loading if the + * [interestsSelectionState] is loading. This is controllable to permit du-duplicating loading + * states. + */ +private fun LazyListScope.InterestsSelection( + interestsSelectionState: ForYouInterestsSelectionState, + showLoadingUIIfLoading: Boolean, + onAuthorCheckedChanged: (String, Boolean) -> Unit, + onTopicCheckedChanged: (String, Boolean) -> Unit, + saveFollowedTopics: () -> Unit +) { + when (interestsSelectionState) { + ForYouInterestsSelectionState.Loading -> { + if (showLoadingUIIfLoading) { item { LoadingWheel( modifier = Modifier @@ -143,101 +217,62 @@ fun ForYouScreen( ) } } - ForYouInterestsSelectionState.NoInterestsSelection -> Unit - is ForYouInterestsSelectionState.WithInterestsSelection -> { - item { - Text( - text = stringResource(R.string.onboarding_guidance_title), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - style = NiaTypography.titleMedium - ) - } - item { - Text( - text = stringResource(R.string.onboarding_guidance_subtitle), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, start = 16.dp, end = 16.dp), - textAlign = TextAlign.Center, - style = NiaTypography.bodyMedium - ) - } - item { - AuthorsCarousel( - authors = interestsSelectionState.authors, - onAuthorClick = onAuthorCheckedChanged, - modifier = Modifier.padding(vertical = 8.dp) - ) - } - item { - TopicSelection( - interestsSelectionState, - onTopicCheckedChanged, - Modifier.padding(bottom = 8.dp) - ) - } - item { - // Done button - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Button( - onClick = saveFollowedTopics, - enabled = interestsSelectionState.canSaveInterests, - modifier = Modifier - .padding(horizontal = 40.dp) - .width(364.dp) - ) { - Text(text = stringResource(R.string.done)) - } - } - } - } } - - when (feedState) { - ForYouFeedState.Loading -> { - // Avoid showing a second loading wheel if we already are for the interests - // selection - if (interestsSelectionState !is ForYouInterestsSelectionState.Loading) { - item { - LoadingWheel( - modifier = Modifier - .fillMaxWidth() - .wrapContentSize(), - contentDesc = stringResource(id = R.string.for_you_loading), - ) - } - } + ForYouInterestsSelectionState.NoInterestsSelection -> Unit + is ForYouInterestsSelectionState.WithInterestsSelection -> { + item { + Text( + text = stringResource(R.string.onboarding_guidance_title), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + style = NiaTypography.titleMedium + ) } - is ForYouFeedState.Success -> { - newsResourceCardItems( - items = feedState.feed, - newsResourceMapper = SaveableNewsResource::newsResource, - isBookmarkedMapper = SaveableNewsResource::isSaved, - onToggleBookmark = { saveableNewsResource -> - onNewsResourcesCheckedChanged( - saveableNewsResource.newsResource.id, - !saveableNewsResource.isSaved - ) - }, - itemModifier = Modifier.padding(24.dp) + item { + Text( + text = stringResource(R.string.onboarding_guidance_subtitle), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 16.dp, end = 16.dp), + textAlign = TextAlign.Center, + style = NiaTypography.bodyMedium ) } - } - - item { - Spacer( - // TODO: Replace with windowInsetsBottomHeight after - // https://issuetracker.google.com/issues/230383055 - Modifier.windowInsetsPadding( - WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom) + item { + AuthorsCarousel( + authors = interestsSelectionState.authors, + onAuthorClick = onAuthorCheckedChanged, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) ) - ) + } + item { + TopicSelection( + interestsSelectionState, + onTopicCheckedChanged, + Modifier.padding(bottom = 8.dp) + ) + } + item { + // Done button + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = saveFollowedTopics, + enabled = interestsSelectionState.canSaveInterests, + modifier = Modifier + .padding(horizontal = 40.dp) + .width(364.dp) + ) { + Text(text = stringResource(R.string.done)) + } + } + } } } } @@ -249,7 +284,7 @@ private fun TopicSelection( modifier: Modifier = Modifier ) { LazyHorizontalGrid( - rows = Fixed(3), + rows = GridCells.Fixed(3), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(24.dp), @@ -342,12 +377,99 @@ fun TopicIcon( ) } -@Preview +/** + * An extension on [LazyListScope] defining the feed portion of the for you screen. + * Depending on the [feedState], this might emit no items. + * + * @param showLoadingUIIfLoading if true, show a visual indication of loading if the + * [feedState] is loading. This is controllable to permit du-duplicating loading + * states. + */ +private fun LazyListScope.Feed( + feedState: ForYouFeedState, + showLoadingUIIfLoading: Boolean, + @IntRange(from = 1) numberOfColumns: Int, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit +) { + when (feedState) { + ForYouFeedState.Loading -> { + if (showLoadingUIIfLoading) { + item { + LoadingWheel( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(), + contentDesc = stringResource(id = R.string.for_you_loading), + ) + } + } + } + is ForYouFeedState.Success -> { + items( + feedState.feed.chunked(numberOfColumns) + ) { saveableNewsResources -> + Row( + modifier = Modifier.padding( + top = 32.dp, + start = 16.dp, + end = 16.dp + ), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + // The last row may not be complete, but for a consistent grid + // structure we still want an element taking up the empty space. + // Therefore, the last row may have empty boxes. + repeat(numberOfColumns) { index -> + Box( + modifier = Modifier.weight(1f) + ) { + val saveableNewsResource = + saveableNewsResources.getOrNull(index) + + if (saveableNewsResource != null) { + val launchResourceIntent = + Intent( + Intent.ACTION_VIEW, + Uri.parse(saveableNewsResource.newsResource.url) + ) + val context = LocalContext.current + + NewsResourceCardExpanded( + newsResource = saveableNewsResource.newsResource, + isBookmarked = saveableNewsResource.isSaved, + onClick = { + ContextCompat.startActivity( + context, + launchResourceIntent, + null + ) + }, + onToggleBookmark = { + onNewsResourcesCheckedChanged( + saveableNewsResource.newsResource.id, + !saveableNewsResource.isSaved + ) + } + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") +@Preview(device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") +@Preview(device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") @Composable fun ForYouScreenLoading() { - MaterialTheme { - Surface { + BoxWithConstraints { + NiaTheme { ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), interestsSelectionState = ForYouInterestsSelectionState.Loading, feedState = ForYouFeedState.Loading, onTopicCheckedChanged = { _, _ -> }, @@ -359,98 +481,110 @@ fun ForYouScreenLoading() { } } -@Preview +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") +@Preview(device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") +@Preview(device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") @Composable fun ForYouScreenTopicSelection() { - ForYouScreen( - interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( - topics = listOf( - FollowableTopic( - topic = Topic( - id = "0", - name = "Headlines", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" - ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "1", - name = "UI", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" - ), - isFollowed = false - ), - FollowableTopic( - topic = Topic( - id = "2", - name = "Tools", - shortDescription = "", - longDescription = "", - url = "", - imageUrl = "" - ), - isFollowed = false - ), - ), - authors = listOf( - FollowableAuthor( - author = Author( - id = "0", - name = "Android Dev", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", + BoxWithConstraints { + NiaTheme { + ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), + interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( + topics = listOf( + FollowableTopic( + topic = Topic( + id = "0", + name = "Headlines", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false + ), + FollowableTopic( + topic = Topic( + id = "1", + name = "UI", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false + ), + FollowableTopic( + topic = Topic( + id = "2", + name = "Tools", + shortDescription = "", + longDescription = "", + url = "", + imageUrl = "" + ), + isFollowed = false + ), ), - isFollowed = false + authors = listOf( + FollowableAuthor( + author = Author( + id = "0", + name = "Android Dev", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false + ), + FollowableAuthor( + author = Author( + id = "1", + name = "Android Dev 2", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false + ), + FollowableAuthor( + author = Author( + id = "2", + name = "Android Dev 3", + imageUrl = "", + twitter = "", + mediumPage = "", + bio = "", + ), + isFollowed = false + ) + ) ), - FollowableAuthor( - author = Author( - id = "1", - name = "Android Dev 2", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ), - isFollowed = false + feedState = ForYouFeedState.Success( + feed = saveableNewsResource, ), - FollowableAuthor( - author = Author( - id = "2", - name = "Android Dev 3", - imageUrl = "", - twitter = "", - mediumPage = "", - bio = "", - ), - isFollowed = false - ) + onAuthorCheckedChanged = { _, _ -> }, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> } ) - ), - feedState = ForYouFeedState.Success( - feed = saveableNewsResource, - ), - onAuthorCheckedChanged = { _, _ -> }, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> } - ) + } + } } -@Preview +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") +@Preview(device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") +@Preview(device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") @Composable fun PopulatedFeed() { - MaterialTheme { - Surface { + BoxWithConstraints { + NiaTheme { ForYouScreen( + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), interestsSelectionState = ForYouInterestsSelectionState.NoInterestsSelection, feedState = ForYouFeedState.Success( feed = saveableNewsResource diff --git a/kokoro/build.sh b/kokoro/build.sh index 68d8a1379..0c3fdd84b 100644 --- a/kokoro/build.sh +++ b/kokoro/build.sh @@ -19,7 +19,7 @@ set -e # Display commands to stderr. set -x -deviceIds=${1:-'Nexus5,Pixel2,Pixel3'} +deviceIds=${1:-'Nexus5,Pixel2,Pixel3,Nexus9'} osVersionIds=${2:-'23,27,30'} GRADLE_FLAGS=()