[NiA] Implement LazyVerticalGrid for Feed

Change-Id: I0fd51bdd4c64a3d9ceaba05244d384bb9b463315
pull/205/head
Jolanda Verhoef 2 years ago
parent a6250811ec
commit 40cf1b8171

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.navigation package com.google.samples.apps.nowinandroid.navigation
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
@ -43,7 +42,6 @@ fun NiaNavHost(
navController: NavHostController, navController: NavHostController,
onNavigateToDestination: (NiaNavigationDestination, String) -> Unit, onNavigateToDestination: (NiaNavigationDestination, String) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = ForYouDestination.route startDestination: String = ForYouDestination.route
) { ) {
@ -52,10 +50,8 @@ fun NiaNavHost(
startDestination = startDestination, startDestination = startDestination,
modifier = modifier, modifier = modifier,
) { ) {
forYouGraph( forYouGraph()
windowSizeClass = windowSizeClass bookmarksGraph()
)
bookmarksGraph(windowSizeClass)
interestsGraph( interestsGraph(
navigateToTopic = { navigateToTopic = {
onNavigateToDestination( onNavigateToDestination(

@ -105,7 +105,6 @@ fun NiaApp(
navController = appState.navController, navController = appState.navController,
onBackClick = appState::onBackClick, onBackClick = appState::onBackClick,
onNavigateToDestination = appState::navigate, onNavigateToDestination = appState::navigate,
windowSizeClass = appState.windowSizeClass,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumedWindowInsets(padding) .consumedWindowInsets(padding)

@ -20,12 +20,12 @@ import androidx.benchmark.macro.ExperimentalBaselineProfilesApi
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.bookmarks.bookmarksScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectAuthors import com.google.samples.apps.nowinandroid.foryou.forYouSelectAuthors
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import com.google.samples.apps.nowinandroid.saved.savedScrollFeedDownUp
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -55,7 +55,7 @@ class BaselineProfileGenerator {
device.findObject(By.text("Saved")).click() device.findObject(By.text("Saved")).click()
device.waitForIdle() device.waitForIdle()
savedScrollFeedDownUp() bookmarksScrollFeedDownUp()
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() device.findObject(By.text("Interests")).click()

@ -14,20 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.saved package com.google.samples.apps.nowinandroid.bookmarks
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
fun MacrobenchmarkScope.savedWaitForContent() { fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() {
// Wait until content is loaded val feedList = device.findObject(By.res("bookmarks:feed"))
device.wait(Until.hasObject(By.res("saved:feed")), 30_000)
}
fun MacrobenchmarkScope.savedScrollFeedDownUp() {
val feedList = device.findObject(By.res("saved:feed"))
feedList.fling(Direction.DOWN) feedList.fling(Direction.DOWN)
device.waitForIdle() device.waitForIdle()
feedList.fling(Direction.UP) feedList.fling(Direction.UP)

@ -18,18 +18,19 @@ package com.google.samples.apps.nowinandroid.core.ui
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.annotation.IntRange
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.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.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -50,17 +51,16 @@ import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
* [feedState] is loading. This allows a caller to suppress a loading visual if one is already * [feedState] is loading. This allows a caller to suppress a loading visual if one is already
* present in the UI elsewhere. * present in the UI elsewhere.
*/ */
fun LazyListScope.NewsFeed( fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
showLoadingUIIfLoading: Boolean, showLoadingUIIfLoading: Boolean,
@StringRes loadingContentDescription: Int, @StringRes loadingContentDescription: Int,
@IntRange(from = 1) numberOfColumns: Int,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
) { ) {
when (feedState) { when (feedState) {
NewsFeedUiState.Loading -> { NewsFeedUiState.Loading -> {
if (showLoadingUIIfLoading) { if (showLoadingUIIfLoading) {
item { item(span = { GridItemSpan(maxLineSpan) }) {
NiaLoadingWheel( NiaLoadingWheel(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -71,56 +71,24 @@ fun LazyListScope.NewsFeed(
} }
} }
is NewsFeedUiState.Success -> { is NewsFeedUiState.Success -> {
items( items(feedState.feed, key = { it.newsResource.id }) { saveableNewsResource ->
feedState.feed.chunked(numberOfColumns) val resourceUrl by remember {
) { saveableNewsResources -> mutableStateOf(Uri.parse(saveableNewsResource.newsResource.url))
Row( }
modifier = Modifier.padding( val launchResourceIntent = Intent(Intent.ACTION_VIEW, resourceUrl)
top = 32.dp, val context = LocalContext.current
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( NewsResourceCardExpanded(
newsResource = saveableNewsResource.newsResource, newsResource = saveableNewsResource.newsResource,
isBookmarked = saveableNewsResource.isSaved, isBookmarked = saveableNewsResource.isSaved,
onClick = { onClick = { ContextCompat.startActivity(context, launchResourceIntent, null) },
ContextCompat.startActivity( onToggleBookmark = {
context, onNewsResourcesCheckedChanged(
launchResourceIntent, saveableNewsResource.newsResource.id,
null !saveableNewsResource.isSaved
) )
},
onToggleBookmark = {
onNewsResourcesCheckedChanged(
saveableNewsResource.newsResource.id,
!saveableNewsResource.isSaved
)
}
)
}
}
} }
} )
} }
} }
} }
@ -150,12 +118,11 @@ sealed interface NewsFeedUiState {
@Composable @Composable
fun NewsFeedLoadingPreview() { fun NewsFeedLoadingPreview() {
NiaTheme { NiaTheme {
LazyColumn { LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
NewsFeed( newsFeed(
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
showLoadingUIIfLoading = true, showLoadingUIIfLoading = true,
loadingContentDescription = 0, loadingContentDescription = 0,
numberOfColumns = 1,
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
} }
@ -163,39 +130,19 @@ fun NewsFeedLoadingPreview() {
} }
@Preview @Preview
@Composable
fun NewsFeedSingleColumnPreview() {
NiaTheme {
LazyColumn {
NewsFeed(
feedState = NewsFeedUiState.Success(
previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
numberOfColumns = 1,
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
@Preview(device = Devices.TABLET) @Preview(device = Devices.TABLET)
@Composable @Composable
fun NewsFeedTwoColumnPreview() { fun NewsFeedContentPreview() {
NiaTheme { NiaTheme {
LazyColumn { LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
NewsFeed( newsFeed(
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
(previewNewsResources + previewNewsResources).map { previewNewsResources.map {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)
} }
), ),
showLoadingUIIfLoading = true, showLoadingUIIfLoading = true,
loadingContentDescription = 0, loadingContentDescription = 0,
numberOfColumns = 2,
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
} }

@ -17,8 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks
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.WindowSizeClass
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
@ -33,7 +31,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
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.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -45,7 +42,6 @@ import org.junit.Test
/** /**
* UI tests for [BookmarksScreen] composable. * UI tests for [BookmarksScreen] composable.
*/ */
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class BookmarksScreenTest { class BookmarksScreenTest {
@get:Rule @get:Rule
@ -53,18 +49,11 @@ class BookmarksScreenTest {
@Test @Test
fun loading_showsLoadingSpinner() { fun loading_showsLoadingSpinner() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BookmarksScreen(
windowSizeClass = WindowSizeClass.calculateFromSize( feedState = NewsFeedUiState.Loading,
DpSize(maxWidth, maxHeight) removeFromBookmarks = { }
) )
BookmarksScreen(
windowSizeClass = windowSizeClass,
feedState = NewsFeedUiState.Loading,
removeFromBookmarks = { }
)
}
} }
composeTestRule composeTestRule
@ -79,20 +68,13 @@ class BookmarksScreenTest {
lateinit var windowSizeClass: WindowSizeClass lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BookmarksScreen(
windowSizeClass = WindowSizeClass.calculateFromSize( feedState = NewsFeedUiState.Success(
DpSize(maxWidth, maxHeight) previewNewsResources.take(2)
) .map { SaveableNewsResource(it, true) }
),
BookmarksScreen( removeFromBookmarks = { }
windowSizeClass = windowSizeClass, )
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
),
removeFromBookmarks = { }
)
}
} }
composeTestRule composeTestRule
@ -122,28 +104,19 @@ class BookmarksScreenTest {
@Test @Test
fun feed_whenRemovingBookmark_removesBookmark() { fun feed_whenRemovingBookmark_removesBookmark() {
lateinit var windowSizeClass: WindowSizeClass
var removeFromBookmarksCalled = false var removeFromBookmarksCalled = false
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BookmarksScreen(
windowSizeClass = WindowSizeClass.calculateFromSize( feedState = NewsFeedUiState.Success(
DpSize(maxWidth, maxHeight) previewNewsResources.take(2)
) .map { SaveableNewsResource(it, true) }
),
BookmarksScreen( removeFromBookmarks = { newsResourceId ->
windowSizeClass = windowSizeClass, assertEquals(previewNewsResources[0].id, newsResourceId)
feedState = NewsFeedUiState.Success( removeFromBookmarksCalled = true
previewNewsResources.take(2) }
.map { SaveableNewsResource(it, true) } )
),
removeFromBookmarks = { newsResourceId ->
assertEquals(previewNewsResources[0].id, newsResourceId)
removeFromBookmarksCalled = true
}
)
}
} }
composeTestRule composeTestRule

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -29,12 +29,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn 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.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
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
@ -47,19 +47,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.NewsFeed
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.math.floor import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable @Composable
fun BookmarksRoute( fun BookmarksRoute(
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel() viewModel: BookmarksViewModel = hiltViewModel()
) { ) {
val feedState by viewModel.feedState.collectAsState() val feedState by viewModel.feedState.collectAsState()
BookmarksScreen( BookmarksScreen(
windowSizeClass = windowSizeClass,
feedState = feedState, feedState = feedState,
removeFromBookmarks = viewModel::removeFromSavedResources, removeFromBookmarks = viewModel::removeFromSavedResources,
modifier = modifier modifier = modifier
@ -69,7 +66,6 @@ fun BookmarksRoute(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun BookmarksScreen( fun BookmarksScreen(
windowSizeClass: WindowSizeClass,
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -97,37 +93,26 @@ fun BookmarksScreen(
}, },
containerColor = Color.Transparent containerColor = Color.Transparent
) { innerPadding -> ) { innerPadding ->
// TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed: LazyVerticalGrid(
// https://issuetracker.google.com/issues/230514914 columns = Adaptive(300.dp),
// https://issuetracker.google.com/issues/231320714 contentPadding = PaddingValues(16.dp),
BoxWithConstraints( horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier modifier = modifier
.fillMaxSize()
.testTag("bookmarks:feed")
.padding(innerPadding) .padding(innerPadding)
.consumedWindowInsets(innerPadding) .consumedWindowInsets(innerPadding)
) { ) {
val numberOfColumns = when (windowSizeClass.widthSizeClass) { newsFeed(
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> 1 feedState = feedState,
else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1) onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
} showLoadingUIIfLoading = true,
loadingContentDescription = R.string.saved_loading
LazyColumn( )
modifier = Modifier
.fillMaxSize()
.testTag("saved:feed"),
contentPadding = PaddingValues(bottom = 16.dp)
) {
NewsFeed(
feedState = feedState,
numberOfColumns = numberOfColumns,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
showLoadingUIIfLoading = true,
loadingContentDescription = R.string.saved_loading
)
item { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
} }
} }

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks.navigation package com.google.samples.apps.nowinandroid.feature.bookmarks.navigation
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
@ -27,10 +26,8 @@ object BookmarksDestination : NiaNavigationDestination {
override val destination = "bookmarks_destination" override val destination = "bookmarks_destination"
} }
fun NavGraphBuilder.bookmarksGraph( fun NavGraphBuilder.bookmarksGraph() {
windowSizeClass: WindowSizeClass
) {
composable(route = BookmarksDestination.route) { composable(route = BookmarksDestination.route) {
BookmarksRoute(windowSizeClass) BookmarksRoute()
} }
} }

@ -25,7 +25,5 @@ plugins {
dependencies { dependencies {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
} }

@ -18,11 +18,7 @@ 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.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasContentDescription
@ -33,21 +29,16 @@ 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.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.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.datetime.Instant
import org.junit.Assert
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>()
@ -63,13 +54,10 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -88,9 +76,6 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics, topics = testTopics,
@ -99,8 +84,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -110,14 +95,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor -> testAuthors.forEach { testAuthor ->
composeTestRule composeTestRule
.onNodeWithText(testAuthor.author.name) .onNodeWithText(testAuthor.author.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
@ -129,7 +114,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNode(doneButtonMatcher) .onNode(doneButtonMatcher)
.assertIsDisplayed() .assertExists()
.assertIsNotEnabled() .assertIsNotEnabled()
.assertHasClickAction() .assertHasClickAction()
} }
@ -139,9 +124,6 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic // Follow one topic
@ -153,8 +135,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -164,14 +146,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor -> testAuthors.forEach { testAuthor ->
composeTestRule composeTestRule
.onNodeWithText(testAuthor.author.name) .onNodeWithText(testAuthor.author.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
@ -183,7 +165,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNode(doneButtonMatcher) .onNode(doneButtonMatcher)
.assertIsDisplayed() .assertExists()
.assertIsEnabled() .assertIsEnabled()
.assertHasClickAction() .assertHasClickAction()
} }
@ -193,9 +175,6 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic // Follow one topic
@ -207,8 +186,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -218,14 +197,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor -> testAuthors.forEach { testAuthor ->
composeTestRule composeTestRule
.onNodeWithText(testAuthor.author.name) .onNodeWithText(testAuthor.author.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
@ -237,7 +216,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNode(doneButtonMatcher) .onNode(doneButtonMatcher)
.assertIsDisplayed() .assertExists()
.assertIsEnabled() .assertIsEnabled()
.assertHasClickAction() .assertHasClickAction()
} }
@ -247,17 +226,14 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics, topics = testTopics,
authors = testAuthors authors = testAuthors
), ),
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -286,13 +262,10 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -318,60 +291,44 @@ class ForYouScreenTest {
@Test @Test
fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() { fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize( interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
DpSize(maxWidth, maxHeight) feedState = NewsFeedUiState.Success(
) feed = previewNewsResources.map {
SaveableNewsResource(it, false)
ForYouScreen( }
windowSizeClass = windowSizeClass, ),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, onTopicCheckedChanged = { _, _ -> },
feedState = NewsFeedUiState.Success( onAuthorCheckedChanged = { _, _ -> },
feed = testNewsResources saveFollowedTopics = {},
), onNewsResourcesCheckedChanged = { _, _ -> }
onAuthorCheckedChanged = { _, _ -> }, )
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
} }
val firstFeedItem = composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
testNewsResources[0].newsResource.title, previewNewsResources[0].title,
substring = true substring = true
) )
.assertExists()
.assertHasClickAction() .assertHasClickAction()
.fetchSemanticsNode()
val secondFeedItem = composeTestRule composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText( .onNodeWithText(
testNewsResources[1].newsResource.title, previewNewsResources[1].title,
substring = true substring = true
) )
.assertExists()
.assertHasClickAction() .assertHasClickAction()
.fetchSemanticsNode()
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> {
// On smaller screen widths, the second feed item should be below the first because
// they are displayed in a single column
Assert.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
Assert.assertTrue(
firstFeedItem.positionInRoot.y == secondFeedItem.positionInRoot.y
)
}
}
} }
} }
@ -415,71 +372,3 @@ private val testAuthors = listOf(
isFollowed = false isFollowed = false
), ),
) )
private val testNewsResources = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = "1",
episodeId = "52",
title = "Small Title",
content = "small.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = null,
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = emptyList(),
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
),
)

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import android.app.Activity import android.app.Activity
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -37,14 +38,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
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.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -54,9 +56,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -70,7 +69,6 @@ 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
@ -93,36 +91,32 @@ import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.NewsFeed
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState 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.TrackScrollJank
import kotlin.math.floor import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class) @OptIn(ExperimentalLifecycleComposeApi::class)
@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.collectAsStateWithLifecycle() val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle()
ForYouScreen( ForYouScreen(
windowSizeClass = windowSizeClass,
modifier = modifier,
interestsSelectionState = interestsSelectionState, interestsSelectionState = interestsSelectionState,
feedState = feedState, feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection, onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests, saveFollowedTopics = viewModel::saveFollowedInterests,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier
) )
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ForYouScreen( fun ForYouScreen(
windowSizeClass: WindowSizeClass,
interestsSelectionState: ForYouInterestsSelectionUiState, interestsSelectionState: ForYouInterestsSelectionUiState,
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
@ -154,73 +148,63 @@ fun ForYouScreen(
}, },
containerColor = Color.Transparent containerColor = Color.Transparent
) { innerPadding -> ) { innerPadding ->
// TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed: // Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// https://issuetracker.google.com/issues/230514914 // This code should be called when the UI is ready for use
// https://issuetracker.google.com/issues/231320714 // and relates to Time To Full Display.
BoxWithConstraints( val interestsLoaded =
modifier = modifier interestsSelectionState !is ForYouInterestsSelectionUiState.Loading
.padding(innerPadding) val feedLoaded = feedState !is NewsFeedUiState.Loading
.consumedWindowInsets(innerPadding)
) {
val numberOfColumns = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> 1
else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1)
}
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// This code should be called when the UI is ready for use
// and relates to Time To Full Display.
val interestsLoaded =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading
val feedLoaded = feedState !is NewsFeedUiState.Loading
if (interestsLoaded && feedLoaded) { if (interestsLoaded && feedLoaded) {
val localView = LocalView.current val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition, // We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope. // however it will be called again if this composable goes out of scope.
// Activity.reportFullyDrawn() has its own check for this // Activity.reportFullyDrawn() has its own check for this
// and is safe to call multiple times though. // and is safe to call multiple times though.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// We're leveraging the fact, that the current view is directly set as content of Activity. // We're leveraging the fact, that the current view is directly set as content of Activity.
val activity = localView.context as? Activity ?: return@LaunchedEffect val activity = localView.context as? Activity ?: return@LaunchedEffect
// To be sure not to call in the middle of a frame draw. // To be sure not to call in the middle of a frame draw.
localView.doOnPreDraw { activity.reportFullyDrawn() } localView.doOnPreDraw { activity.reportFullyDrawn() }
}
} }
}
val tag = "forYou:feed" val tag = "forYou:feed"
val lazyListState = rememberLazyListState() val lazyGridState = rememberLazyGridState()
TrackScrollJank(scrollableState = lazyListState, stateName = tag) TrackScrollJank(scrollableState = lazyGridState, stateName = tag)
LazyColumn( LazyVerticalGrid(
modifier = Modifier columns = Adaptive(300.dp),
.fillMaxSize() contentPadding = PaddingValues(16.dp),
.testTag(tag), horizontalArrangement = Arrangement.spacedBy(32.dp),
state = lazyListState, verticalArrangement = Arrangement.spacedBy(24.dp),
) { modifier = modifier
InterestsSelection( .padding(innerPadding)
interestsSelectionState = interestsSelectionState, .consumedWindowInsets(innerPadding)
showLoadingUIIfLoading = true, .fillMaxSize()
onAuthorCheckedChanged = onAuthorCheckedChanged, .testTag("forYou:feed"),
onTopicCheckedChanged = onTopicCheckedChanged, state = lazyGridState
saveFollowedTopics = saveFollowedTopics ) {
) interestsSelection(
interestsSelectionState = interestsSelectionState,
onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics
)
NewsFeed( newsFeed(
feedState = feedState, feedState = feedState,
// Avoid showing a second loading wheel if we already are for the interests // Avoid showing a second loading wheel if we already are for the interests
// selection // selection
showLoadingUIIfLoading = showLoadingUIIfLoading =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading, interestsSelectionState !is ForYouInterestsSelectionUiState.Loading,
numberOfColumns = numberOfColumns, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, loadingContentDescription = R.string.for_you_loading
loadingContentDescription = R.string.for_you_loading )
)
item { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
} }
} }
@ -231,85 +215,76 @@ fun ForYouScreen(
* An extension on [LazyListScope] defining the interests selection portion of the for you screen. * An extension on [LazyListScope] defining the interests selection portion of the for you screen.
* Depending on the [interestsSelectionState], this might emit no items. * Depending on the [interestsSelectionState], this might emit no items.
* *
* @param showLoadingUIIfLoading if true, show a visual indication of loading if the * @param showLoaderWhenLoading if true, show a visual indication of loading if the
* [interestsSelectionState] is loading. This is controllable to permit du-duplicating loading * [interestsSelectionState] is loading. This is controllable to permit du-duplicating loading
* states. * states.
*/ */
private fun LazyListScope.InterestsSelection( private fun LazyGridScope.interestsSelection(
interestsSelectionState: ForYouInterestsSelectionUiState, interestsSelectionState: ForYouInterestsSelectionUiState,
showLoadingUIIfLoading: Boolean,
onAuthorCheckedChanged: (String, Boolean) -> Unit, onAuthorCheckedChanged: (String, Boolean) -> Unit,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit saveFollowedTopics: () -> Unit
) { ) {
when (interestsSelectionState) { when (interestsSelectionState) {
ForYouInterestsSelectionUiState.Loading -> { ForYouInterestsSelectionUiState.Loading -> {
if (showLoadingUIIfLoading) { item(span = { GridItemSpan(maxLineSpan) }) {
item { NiaLoadingWheel(
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
}
ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
item {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 24.dp), .wrapContentSize()
style = MaterialTheme.typography.titleMedium .testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
) )
} }
item { }
Text( ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
text = stringResource(R.string.onboarding_guidance_subtitle), is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
modifier = Modifier item(span = { GridItemSpan(maxLineSpan) }) {
.fillMaxWidth() Column {
.padding(top = 8.dp, start = 16.dp, end = 16.dp), Text(
textAlign = TextAlign.Center, text = stringResource(R.string.onboarding_guidance_title),
style = MaterialTheme.typography.bodyMedium textAlign = TextAlign.Center,
) modifier = Modifier
} .fillMaxWidth()
item { .padding(top = 24.dp),
AuthorsCarousel( style = MaterialTheme.typography.titleMedium
authors = interestsSelectionState.authors, )
onAuthorClick = onAuthorCheckedChanged, Text(
modifier = Modifier text = stringResource(R.string.onboarding_guidance_subtitle),
.fillMaxWidth() modifier = Modifier
.padding(vertical = 8.dp) .fillMaxWidth()
) .padding(top = 8.dp, start = 16.dp, end = 16.dp),
} textAlign = TextAlign.Center,
item { style = MaterialTheme.typography.bodyMedium
TopicSelection( )
interestsSelectionState, AuthorsCarousel(
onTopicCheckedChanged, authors = interestsSelectionState.authors,
Modifier.padding(bottom = 8.dp) onAuthorClick = onAuthorCheckedChanged,
)
}
item {
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
NiaFilledButton(
onClick = saveFollowedTopics,
enabled = interestsSelectionState.canSaveInterests,
modifier = Modifier modifier = Modifier
.padding(horizontal = 40.dp) .fillMaxWidth()
.width(364.dp) .padding(vertical = 8.dp)
)
TopicSelection(
interestsSelectionState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) { ) {
Text( NiaFilledButton(
text = stringResource(R.string.done) onClick = saveFollowedTopics,
) enabled = interestsSelectionState.canSaveInterests,
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
) {
Text(
text = stringResource(R.string.done)
)
}
} }
} }
} }
@ -428,7 +403,6 @@ fun TopicIcon(
) )
} }
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@ -438,7 +412,6 @@ fun ForYouScreenPopulatedFeed() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
@ -454,7 +427,6 @@ fun ForYouScreenPopulatedFeed() {
} }
} }
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@ -464,7 +436,6 @@ fun ForYouScreenTopicSelection() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = previewTopics.map { FollowableTopic(it, false) }, topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) } authors = previewAuthors.map { FollowableAuthor(it, false) }
@ -474,8 +445,8 @@ fun ForYouScreenTopicSelection() {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)
} }
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -483,7 +454,6 @@ fun ForYouScreenTopicSelection() {
} }
} }
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@ -493,7 +463,6 @@ fun ForYouScreenLoading() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.foryou.navigation package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
@ -27,10 +26,8 @@ object ForYouDestination : NiaNavigationDestination {
override val destination = "for_you_destination" override val destination = "for_you_destination"
} }
fun NavGraphBuilder.forYouGraph( fun NavGraphBuilder.forYouGraph() {
windowSizeClass: WindowSizeClass
) {
composable(route = ForYouDestination.route) { composable(route = ForYouDestination.route) {
ForYouRoute(windowSizeClass) ForYouRoute()
} }
} }

Loading…
Cancel
Save