Add multi-column support for cards on the for you feed

Fixes: 228074722

Change-Id: Id077b4dd93452b5f9399f94161c9b66594436975
pull/2/head
Alex Vanyo 2 years ago committed by Don Turner
parent 59c5979c0a
commit cce9402b04

@ -107,6 +107,7 @@ fun NiaApp(windowSizeClass: WindowSizeClass) {
} }
NiaNavGraph( NiaNavGraph(
windowSizeClass = windowSizeClass,
navController = navController, navController = navController,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@ -42,6 +43,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
*/ */
@Composable @Composable
fun NiaNavGraph( fun NiaNavGraph(
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
startDestination: String = NiaDestinations.FOR_YOU_ROUTE startDestination: String = NiaDestinations.FOR_YOU_ROUTE
@ -52,7 +54,7 @@ fun NiaNavGraph(
modifier = modifier, modifier = modifier,
) { ) {
composable(NiaDestinations.FOR_YOU_ROUTE) { composable(NiaDestinations.FOR_YOU_ROUTE) {
ForYouRoute() ForYouRoute(windowSizeClass)
} }
composable(NiaDestinations.EPISODES_ROUTE) { composable(NiaDestinations.EPISODES_ROUTE) {
Text("EPISODES") Text("EPISODES")

@ -40,6 +40,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.androidx.lifecycle.viewModelCompose)

@ -17,6 +17,11 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.activity.ComponentActivity 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.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled 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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode 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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor 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.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 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.Rule
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class ForYouScreenTest { class ForYouScreenTest {
@get:Rule @get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>() val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@ -50,14 +62,19 @@ class ForYouScreenTest {
@Test @Test
fun circularProgressIndicator_whenScreenIsLoading_exists() { fun circularProgressIndicator_whenScreenIsLoading_exists() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( BoxWithConstraints {
interestsSelectionState = ForYouInterestsSelectionState.Loading, ForYouScreen(
feedState = ForYouFeedState.Loading, windowSizeClass = WindowSizeClass.calculateFromSize(
onAuthorCheckedChanged = { _, _ -> }, DpSize(maxWidth, maxHeight)
onTopicCheckedChanged = { _, _ -> }, ),
saveFollowedTopics = {}, interestsSelectionState = ForYouInterestsSelectionState.Loading,
onNewsResourcesCheckedChanged = { _, _ -> } feedState = ForYouFeedState.Loading,
) onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
} }
composeTestRule composeTestRule
@ -70,76 +87,81 @@ class ForYouScreenTest {
@Test @Test
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() { fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( BoxWithConstraints {
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( ForYouScreen(
topics = listOf( windowSizeClass = WindowSizeClass.calculateFromSize(
FollowableTopic( DpSize(maxWidth, maxHeight)
topic = Topic( ),
id = "0", interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
name = "Headlines", topics = listOf(
shortDescription = "", FollowableTopic(
longDescription = "", topic = Topic(
url = "", id = "0",
imageUrl = "" name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
), ),
isFollowed = false FollowableTopic(
), topic = Topic(
FollowableTopic( id = "1",
topic = Topic( name = "UI",
id = "1", shortDescription = "",
name = "UI", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = false
), ),
isFollowed = false FollowableTopic(
), topic = Topic(
FollowableTopic( id = "2",
topic = Topic( name = "Tools",
id = "2", shortDescription = "",
name = "Tools", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = false
), ),
isFollowed = false
), ),
), authors = listOf(
authors = listOf( FollowableAuthor(
FollowableAuthor( author = Author(
author = Author( id = "0",
id = "0", name = "Android Dev",
name = "Android Dev", imageUrl = "",
imageUrl = "", twitter = "",
twitter = "", mediumPage = "",
mediumPage = "", bio = "",
bio = "", ),
isFollowed = false
), ),
isFollowed = false FollowableAuthor(
), author = Author(
FollowableAuthor( id = "1",
author = Author( name = "Android Dev 2",
id = "1", imageUrl = "",
name = "Android Dev 2", twitter = "",
imageUrl = "", mediumPage = "",
twitter = "", bio = "",
mediumPage = "", ),
bio = "", isFollowed = false
), ),
isFollowed = false )
), ),
) feedState = ForYouFeedState.Success(
), feed = emptyList()
feedState = ForYouFeedState.Success( ),
feed = emptyList() onAuthorCheckedChanged = { _, _ -> },
), onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, saveFollowedTopics = {},
onTopicCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> }
saveFollowedTopics = {}, )
onNewsResourcesCheckedChanged = { _, _ -> } }
)
} }
composeTestRule composeTestRule
@ -173,76 +195,81 @@ class ForYouScreenTest {
@Test @Test
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( BoxWithConstraints {
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( ForYouScreen(
topics = listOf( windowSizeClass = WindowSizeClass.calculateFromSize(
FollowableTopic( DpSize(maxWidth, maxHeight)
topic = Topic( ),
id = "0", interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
name = "Headlines", topics = listOf(
shortDescription = "", FollowableTopic(
longDescription = "", topic = Topic(
url = "", id = "0",
imageUrl = "" name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
), ),
isFollowed = false FollowableTopic(
), topic = Topic(
FollowableTopic( id = "1",
topic = Topic( name = "UI",
id = "1", shortDescription = "",
name = "UI", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = true
), ),
isFollowed = true FollowableTopic(
), topic = Topic(
FollowableTopic( id = "2",
topic = Topic( name = "Tools",
id = "2", shortDescription = "",
name = "Tools", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = false
), ),
isFollowed = false
), ),
), authors = listOf(
authors = listOf( FollowableAuthor(
FollowableAuthor( author = Author(
author = Author( id = "0",
id = "0", name = "Android Dev",
name = "Android Dev", imageUrl = "",
imageUrl = "", twitter = "",
twitter = "", mediumPage = "",
mediumPage = "", bio = "",
bio = "", ),
isFollowed = false
), ),
isFollowed = false FollowableAuthor(
), author = Author(
FollowableAuthor( id = "1",
author = Author( name = "Android Dev 2",
id = "1", imageUrl = "",
name = "Android Dev 2", twitter = "",
imageUrl = "", mediumPage = "",
twitter = "", bio = "",
mediumPage = "", ),
bio = "", isFollowed = false
), ),
isFollowed = false
), ),
), ),
), feedState = ForYouFeedState.Success(
feedState = ForYouFeedState.Success( feed = emptyList()
feed = emptyList() ),
), onAuthorCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {},
saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }
onNewsResourcesCheckedChanged = { _, _ -> } )
) }
} }
composeTestRule composeTestRule
@ -282,76 +309,81 @@ class ForYouScreenTest {
@Test @Test
fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( BoxWithConstraints {
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( ForYouScreen(
topics = listOf( windowSizeClass = WindowSizeClass.calculateFromSize(
FollowableTopic( DpSize(maxWidth, maxHeight)
topic = Topic( ),
id = "0", interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
name = "Headlines", topics = listOf(
shortDescription = "", FollowableTopic(
longDescription = "", topic = Topic(
url = "", id = "0",
imageUrl = "" name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
), ),
isFollowed = false FollowableTopic(
), topic = Topic(
FollowableTopic( id = "1",
topic = Topic( name = "UI",
id = "1", shortDescription = "",
name = "UI", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = true
), ),
isFollowed = true FollowableTopic(
), topic = Topic(
FollowableTopic( id = "2",
topic = Topic( name = "Tools",
id = "2", shortDescription = "",
name = "Tools", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = false
), ),
isFollowed = false
), ),
), authors = listOf(
authors = listOf( FollowableAuthor(
FollowableAuthor( author = Author(
author = Author( id = "0",
id = "0", name = "Android Dev",
name = "Android Dev", imageUrl = "",
imageUrl = "", twitter = "",
twitter = "", mediumPage = "",
mediumPage = "", bio = "",
bio = "", ),
isFollowed = false
), ),
isFollowed = false FollowableAuthor(
), author = Author(
FollowableAuthor( id = "1",
author = Author( name = "Android Dev 2",
id = "1", imageUrl = "",
name = "Android Dev 2", twitter = "",
imageUrl = "", mediumPage = "",
twitter = "", bio = "",
mediumPage = "", ),
bio = "", isFollowed = false
), ),
isFollowed = false
), ),
), ),
), feedState = ForYouFeedState.Success(
feedState = ForYouFeedState.Success( feed = emptyList()
feed = emptyList() ),
), onAuthorCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {},
saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }
onNewsResourcesCheckedChanged = { _, _ -> } )
) }
} }
composeTestRule composeTestRule
@ -391,74 +423,114 @@ class ForYouScreenTest {
@Test @Test
fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() { fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( BoxWithConstraints {
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( ForYouScreen(
topics = listOf( windowSizeClass = WindowSizeClass.calculateFromSize(
FollowableTopic( DpSize(maxWidth, maxHeight)
topic = Topic( ),
id = "0", interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
name = "Headlines", topics = listOf(
shortDescription = "", FollowableTopic(
longDescription = "", topic = Topic(
url = "", id = "0",
imageUrl = "" name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
), ),
isFollowed = false FollowableTopic(
), topic = Topic(
FollowableTopic( id = "1",
topic = Topic( name = "UI",
id = "1", shortDescription = "",
name = "UI", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = false
), ),
isFollowed = false FollowableTopic(
), topic = Topic(
FollowableTopic( id = "2",
topic = Topic( name = "Tools",
id = "2", shortDescription = "",
name = "Tools", longDescription = "",
shortDescription = "", url = "",
longDescription = "", imageUrl = ""
url = "", ),
imageUrl = "" isFollowed = false
), ),
isFollowed = false
), ),
), authors = listOf(
authors = listOf( FollowableAuthor(
FollowableAuthor( author = Author(
author = Author( id = "0",
id = "0", name = "Android Dev",
name = "Android Dev", imageUrl = "",
imageUrl = "", twitter = "",
twitter = "", mediumPage = "",
mediumPage = "", bio = "",
bio = "", ),
isFollowed = true
), ),
isFollowed = true FollowableAuthor(
), author = Author(
FollowableAuthor( id = "1",
author = Author( name = "Android Dev 2",
id = "1", imageUrl = "",
name = "Android Dev 2", twitter = "",
imageUrl = "", mediumPage = "",
twitter = "", bio = "",
mediumPage = "", ),
bio = "", isFollowed = false
), ),
isFollowed = false
), ),
), ),
), feedState = ForYouFeedState.Loading,
feedState = ForYouFeedState.Loading, onAuthorCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {},
saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }
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 // Scroll until the loading indicator is visible
@ -477,4 +549,153 @@ class ForYouScreenTest {
) )
.assertExists() .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! Heres 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
)
}
}
}
} }

@ -16,7 +16,12 @@
package com.google.samples.apps.nowinandroid.feature.foryou 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn 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.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -47,19 +54,25 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.Author 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.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.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.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar 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.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 com.google.samples.apps.nowinandroid.core.ui.theme.NiaTypography
import kotlin.math.floor
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
@Composable @Composable
fun ForYouRoute( fun ForYouRoute(
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel() viewModel: ForYouViewModel = hiltViewModel()
) { ) {
val interestsSelectionState by viewModel.interestsSelectionState.collectAsState() val interestsSelectionState by viewModel.interestsSelectionState.collectAsState()
val feedState by viewModel.feedState.collectAsState() val feedState by viewModel.feedState.collectAsState()
ForYouScreen( ForYouScreen(
windowSizeClass = windowSizeClass,
modifier = modifier, modifier = modifier,
interestsSelectionState = interestsSelectionState, interestsSelectionState = interestsSelectionState,
feedState = feedState, feedState = feedState,
@ -97,6 +114,7 @@ fun ForYouRoute(
@Composable @Composable
fun ForYouScreen( fun ForYouScreen(
windowSizeClass: WindowSizeClass,
interestsSelectionState: ForYouInterestsSelectionState, interestsSelectionState: ForYouInterestsSelectionState,
feedState: ForYouFeedState, feedState: ForYouFeedState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
@ -105,35 +123,91 @@ fun ForYouScreen(
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( // TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed:
modifier = modifier.fillMaxSize() // https://issuetracker.google.com/issues/230514914
// https://issuetracker.google.com/issues/231320714
BoxWithConstraints(
modifier = modifier
) { ) {
item { val numberOfColumns = when (windowSizeClass.widthSizeClass) {
Spacer( WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> 1
// TODO: Replace with windowInsetsTopHeight after else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1)
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
} }
item { LazyColumn(modifier = Modifier.fillMaxSize()) {
NiaTopAppBar( item {
titleRes = R.string.top_app_bar_title, Spacer(
navigationIcon = Icons.Filled.Search, // TODO: Replace with windowInsetsTopHeight after
navigationIconContentDescription = stringResource( // https://issuetracker.google.com/issues/230383055
id = R.string.top_app_bar_navigation_button_content_desc Modifier.windowInsetsPadding(
), WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
actionIcon = Icons.Outlined.AccountCircle, )
actionIconContentDescription = stringResource( )
id = R.string.top_app_bar_navigation_button_content_desc }
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 { item {
LoadingWheel( LoadingWheel(
modifier = Modifier 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))
}
}
}
}
} }
ForYouInterestsSelectionState.NoInterestsSelection -> Unit
when (feedState) { is ForYouInterestsSelectionState.WithInterestsSelection -> {
ForYouFeedState.Loading -> { item {
// Avoid showing a second loading wheel if we already are for the interests Text(
// selection text = stringResource(R.string.onboarding_guidance_title),
if (interestsSelectionState !is ForYouInterestsSelectionState.Loading) { textAlign = TextAlign.Center,
item { modifier = Modifier
LoadingWheel( .fillMaxWidth()
modifier = Modifier .padding(top = 24.dp),
.fillMaxWidth() style = NiaTypography.titleMedium
.wrapContentSize(), )
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
} }
is ForYouFeedState.Success -> { item {
newsResourceCardItems( Text(
items = feedState.feed, text = stringResource(R.string.onboarding_guidance_subtitle),
newsResourceMapper = SaveableNewsResource::newsResource, modifier = Modifier
isBookmarkedMapper = SaveableNewsResource::isSaved, .fillMaxWidth()
onToggleBookmark = { saveableNewsResource -> .padding(top = 8.dp, start = 16.dp, end = 16.dp),
onNewsResourcesCheckedChanged( textAlign = TextAlign.Center,
saveableNewsResource.newsResource.id, style = NiaTypography.bodyMedium
!saveableNewsResource.isSaved
)
},
itemModifier = Modifier.padding(24.dp)
) )
} }
} item {
AuthorsCarousel(
item { authors = interestsSelectionState.authors,
Spacer( onAuthorClick = onAuthorCheckedChanged,
// TODO: Replace with windowInsetsBottomHeight after modifier = Modifier
// https://issuetracker.google.com/issues/230383055 .fillMaxWidth()
Modifier.windowInsetsPadding( .padding(vertical = 8.dp)
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
) )
) }
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 modifier: Modifier = Modifier
) { ) {
LazyHorizontalGrid( LazyHorizontalGrid(
rows = Fixed(3), rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.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 @Composable
fun ForYouScreenLoading() { fun ForYouScreenLoading() {
MaterialTheme { BoxWithConstraints {
Surface { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionState.Loading, interestsSelectionState = ForYouInterestsSelectionState.Loading,
feedState = ForYouFeedState.Loading, feedState = ForYouFeedState.Loading,
onTopicCheckedChanged = { _, _ -> }, 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 @Composable
fun ForYouScreenTopicSelection() { fun ForYouScreenTopicSelection() {
ForYouScreen( BoxWithConstraints {
interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection( NiaTheme {
topics = listOf( ForYouScreen(
FollowableTopic( windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
topic = Topic( interestsSelectionState = ForYouInterestsSelectionState.WithInterestsSelection(
id = "0", topics = listOf(
name = "Headlines", FollowableTopic(
shortDescription = "", topic = Topic(
longDescription = "", id = "0",
url = "", name = "Headlines",
imageUrl = "" shortDescription = "",
), longDescription = "",
isFollowed = false url = "",
), imageUrl = ""
FollowableTopic( ),
topic = Topic( isFollowed = false
id = "1", ),
name = "UI", FollowableTopic(
shortDescription = "", topic = Topic(
longDescription = "", id = "1",
url = "", name = "UI",
imageUrl = "" shortDescription = "",
), longDescription = "",
isFollowed = false url = "",
), imageUrl = ""
FollowableTopic( ),
topic = Topic( isFollowed = false
id = "2", ),
name = "Tools", FollowableTopic(
shortDescription = "", topic = Topic(
longDescription = "", id = "2",
url = "", name = "Tools",
imageUrl = "" shortDescription = "",
), longDescription = "",
isFollowed = false url = "",
), imageUrl = ""
), ),
authors = listOf( isFollowed = false
FollowableAuthor( ),
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
), ),
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( feedState = ForYouFeedState.Success(
author = Author( feed = saveableNewsResource,
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
), ),
FollowableAuthor( onAuthorCheckedChanged = { _, _ -> },
author = Author( onTopicCheckedChanged = { _, _ -> },
id = "2", saveFollowedTopics = {},
name = "Android Dev 3", onNewsResourcesCheckedChanged = { _, _ -> }
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
) )
), }
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 @Composable
fun PopulatedFeed() { fun PopulatedFeed() {
MaterialTheme { BoxWithConstraints {
Surface { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionState.NoInterestsSelection,
feedState = ForYouFeedState.Success( feedState = ForYouFeedState.Success(
feed = saveableNewsResource feed = saveableNewsResource

@ -19,7 +19,7 @@ set -e
# Display commands to stderr. # Display commands to stderr.
set -x set -x
deviceIds=${1:-'Nexus5,Pixel2,Pixel3'} deviceIds=${1:-'Nexus5,Pixel2,Pixel3,Nexus9'}
osVersionIds=${2:-'23,27,30'} osVersionIds=${2:-'23,27,30'}
GRADLE_FLAGS=() GRADLE_FLAGS=()

Loading…
Cancel
Save