Merge pull request #205 from android/aug1merge

August 1 Merge
pull/155/head
Jolanda Verhoef 2 years ago committed by GitHub
commit bc2fc8bb5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -86,8 +86,8 @@ jobs:
api-level: ${{ matrix.api-level }}
arch: x86_64
disable-animations: true
disk-size: 1500M
heap-size: 512M
disk-size: 2000M
heap-size: 600M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest --stacktrace
- name: Upload test reports

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
@ -43,7 +42,6 @@ fun NiaNavHost(
navController: NavHostController,
onNavigateToDestination: (NiaNavigationDestination, String) -> Unit,
onBackClick: () -> Unit,
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier,
startDestination: String = ForYouDestination.route
) {
@ -52,10 +50,8 @@ fun NiaNavHost(
startDestination = startDestination,
modifier = modifier,
) {
forYouGraph(
windowSizeClass = windowSizeClass
)
bookmarksGraph(windowSizeClass)
forYouGraph()
bookmarksGraph()
interestsGraph(
navigateToTopic = {
onNavigateToDestination(

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

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

@ -14,20 +14,14 @@
* 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.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
fun MacrobenchmarkScope.savedWaitForContent() {
// Wait until content is loaded
device.wait(Until.hasObject(By.res("saved:feed")), 30_000)
}
fun MacrobenchmarkScope.savedScrollFeedDownUp() {
val feedList = device.findObject(By.res("saved:feed"))
fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() {
val feedList = device.findObject(By.res("bookmarks:feed"))
feedList.fling(Direction.DOWN)
device.waitForIdle()
feedList.fling(Direction.UP)

@ -18,6 +18,9 @@ buildscript {
repositories {
google()
mavenCentral()
// Android Build Server
maven { url = uri("../nowinandroid-prebuilts/m2repository") }
}
dependencies {

@ -21,6 +21,9 @@
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_OUT=$DIR/app/build/outputs
export JAVA_HOME="$(cd $DIR/../../../prebuilts/studio/jdk/jdk11/linux && pwd )"
echo "JAVA_HOME=$JAVA_HOME"
export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"
echo "ANDROID_HOME=$ANDROID_HOME"

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

@ -24,6 +24,7 @@ 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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
@ -52,10 +53,11 @@ class AuthorScreenTest {
fun niaLoadingWheel_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
AuthorScreen(
authorState = AuthorUiState.Loading,
newsState = NewsUiState.Loading,
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { }
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
@ -69,10 +71,11 @@ class AuthorScreenTest {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorState = AuthorUiState.Success(testAuthor),
newsState = NewsUiState.Loading,
authorUiState = AuthorUiState.Success(testAuthor),
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { }
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
@ -91,10 +94,18 @@ class AuthorScreenTest {
fun news_whenAuthorIsLoading_isNotShown() {
composeTestRule.setContent {
AuthorScreen(
authorState = AuthorUiState.Loading,
newsState = NewsUiState.Success(sampleNewsResources),
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { },
onFollowClick = { }
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
@ -103,15 +114,24 @@ class AuthorScreenTest {
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
@Test
fun news_whenSuccessAndAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorState = AuthorUiState.Success(testAuthor),
newsState = NewsUiState.Success(sampleNewsResources),
authorUiState = AuthorUiState.Success(testAuthor),
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { },
onFollowClick = { }
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}

@ -56,6 +56,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadi
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
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.SaveableNewsResource
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.ui.newsResourceCardItems
@ -67,24 +68,27 @@ fun AuthorRoute(
modifier: Modifier = Modifier,
viewModel: AuthorViewModel = hiltViewModel(),
) {
val uiState: AuthorScreenUiState by viewModel.uiState.collectAsStateWithLifecycle()
val authorUiState: AuthorUiState by viewModel.authorUiState.collectAsStateWithLifecycle()
val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle()
AuthorScreen(
authorState = uiState.authorState,
newsState = uiState.newsState,
authorUiState = authorUiState,
newsUiState = newsUiState,
modifier = modifier,
onBackClick = onBackClick,
onFollowClick = viewModel::followAuthorToggle,
onBookmarkChanged = viewModel::bookmarkNews,
)
}
@VisibleForTesting
@Composable
internal fun AuthorScreen(
authorState: AuthorUiState,
newsState: NewsUiState,
authorUiState: AuthorUiState,
newsUiState: NewsUiState,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@ -94,7 +98,7 @@ internal fun AuthorScreen(
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (authorState) {
when (authorUiState) {
AuthorUiState.Loading -> {
item {
NiaLoadingWheel(
@ -111,12 +115,13 @@ internal fun AuthorScreen(
AuthorToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
uiState = authorState.followableAuthor,
uiState = authorUiState.followableAuthor,
)
}
authorBody(
author = authorState.followableAuthor.author,
news = newsState
author = authorUiState.followableAuthor.author,
news = newsUiState,
onBookmarkChanged = onBookmarkChanged,
)
}
}
@ -128,13 +133,14 @@ internal fun AuthorScreen(
private fun LazyListScope.authorBody(
author: Author,
news: NewsUiState
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit
) {
item {
AuthorHeader(author)
}
authorCards(news)
authorCards(news, onBookmarkChanged)
}
@Composable
@ -163,14 +169,17 @@ private fun AuthorHeader(author: Author) {
}
}
private fun LazyListScope.authorCards(news: NewsUiState) {
private fun LazyListScope.authorCards(
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit
) {
when (news) {
is NewsUiState.Success -> {
newsResourceCardItems(
items = news.news,
newsResourceMapper = { it },
isBookmarkedMapper = { /* TODO */ false },
onToggleBookmark = { /* TODO */ },
newsResourceMapper = { it.newsResource },
isBookmarkedMapper = { it.isSaved },
onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) },
itemModifier = Modifier.padding(24.dp)
)
}
@ -227,10 +236,18 @@ fun AuthorScreenPopulated() {
NiaTheme {
NiaBackground {
AuthorScreen(
authorState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)),
newsState = NewsUiState.Success(previewNewsResources),
authorUiState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)),
newsUiState = NewsUiState.Success(
previewNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = {},
onFollowClick = {}
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
)
}
}
@ -245,10 +262,11 @@ fun AuthorScreenLoading() {
NiaTheme {
NiaBackground {
AuthorScreen(
authorState = AuthorUiState.Loading,
newsState = NewsUiState.Loading,
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {}
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
)
}
}

@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
@ -50,66 +51,126 @@ class AuthorViewModel @Inject constructor(
savedStateHandle[AuthorDestination.authorIdArg]
)
val authorUiState: StateFlow<AuthorUiState> = authorUiStateStream(
authorId = authorId,
userDataRepository = userDataRepository,
authorsRepository = authorsRepository
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AuthorUiState.Loading
)
val newUiState: StateFlow<NewsUiState> = newsUiStateStream(
authorId = authorId,
userDataRepository = userDataRepository,
newsRepository = newsRepository
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsUiState.Loading
)
fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedAuthorId(authorId, followed)
}
}
fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked)
}
}
}
private fun authorUiStateStream(
authorId: String,
userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
): Flow<AuthorUiState> {
// Observe the followed authors, as they could change over time.
private val followedAuthorIdsStream: Flow<Result<Set<String>>> =
val followedAuthorIdsStream: Flow<Set<String>> =
userDataRepository.userDataStream
.map { it.followedAuthors }
.asResult()
// Observe author information
private val author: Flow<Result<Author>> = authorsRepository.getAuthorStream(
val authorStream: Flow<Author> = authorsRepository.getAuthorStream(
id = authorId
).asResult()
// Observe the News for this author
private val newsStream: Flow<Result<List<NewsResource>>> =
newsRepository.getNewsResourcesStream(
filterAuthorIds = setOf(element = authorId),
filterTopicIds = emptySet()
).asResult()
val uiState: StateFlow<AuthorScreenUiState> =
combine(
followedAuthorIdsStream,
author,
newsStream
) { followedAuthorsResult, authorResult, newsResult ->
val author: AuthorUiState =
if (authorResult is Result.Success && followedAuthorsResult is Result.Success) {
val followed = followedAuthorsResult.data.contains(authorId)
)
return combine(
followedAuthorIdsStream,
authorStream,
::Pair
)
.asResult()
.map { followedAuthorToAuthorResult ->
when (followedAuthorToAuthorResult) {
is Result.Success -> {
val (followedAuthors, author) = followedAuthorToAuthorResult.data
val followed = followedAuthors.contains(authorId)
AuthorUiState.Success(
followableAuthor = FollowableAuthor(
author = authorResult.data,
author = author,
isFollowed = followed
)
)
} else if (
authorResult is Result.Loading || followedAuthorsResult is Result.Loading
) {
}
is Result.Loading -> {
AuthorUiState.Loading
} else {
}
is Result.Error -> {
AuthorUiState.Error
}
val news: NewsUiState = when (newsResult) {
is Result.Success -> NewsUiState.Success(newsResult.data)
is Result.Loading -> NewsUiState.Loading
is Result.Error -> NewsUiState.Error
}
AuthorScreenUiState(author, news)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AuthorScreenUiState(AuthorUiState.Loading, NewsUiState.Loading)
)
}
fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedAuthorId(authorId, followed)
private fun newsUiStateStream(
authorId: String,
newsRepository: NewsRepository,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> {
// Observe news
val newsStream: Flow<List<NewsResource>> = newsRepository.getNewsResourcesStream(
filterAuthorIds = setOf(element = authorId),
filterTopicIds = emptySet()
)
// Observe bookmarks
val bookmarkStream: Flow<Set<String>> = userDataRepository.userDataStream
.map { it.bookmarkedNewsResources }
return combine(
newsStream,
bookmarkStream,
::Pair
)
.asResult()
.map { newsToBookmarksResult ->
when (newsToBookmarksResult) {
is Result.Success -> {
val (news, bookmarks) = newsToBookmarksResult.data
NewsUiState.Success(
news.map { newsResource ->
SaveableNewsResource(
newsResource,
isSaved = bookmarks.contains(newsResource.id)
)
}
)
}
is Result.Loading -> {
NewsUiState.Loading
}
is Result.Error -> {
NewsUiState.Error
}
}
}
}
}
sealed interface AuthorUiState {
@ -119,12 +180,7 @@ sealed interface AuthorUiState {
}
sealed interface NewsUiState {
data class Success(val news: List<NewsResource>) : NewsUiState
data class Success(val news: List<SaveableNewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}
data class AuthorScreenUiState(
val authorState: AuthorUiState,
val newsState: NewsUiState
)

@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -68,16 +69,16 @@ class AuthorViewModelTest {
@Test
fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() }
// To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
val item = viewModel.authorUiState.value
assertTrue(item is AuthorUiState.Success)
val successAuthorUiState = item.authorState as AuthorUiState.Success
val successAuthorUiState = item as AuthorUiState.Success
val authorFromRepository = authorsRepository.getAuthorStream(
id = testInputAuthors[0].author.id
).first()
@ -90,20 +91,20 @@ class AuthorViewModelTest {
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState)
assertEquals(NewsUiState.Loading, viewModel.newUiState.value)
}
@Test
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value)
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value)
collectJob.cancel()
}
@ -111,13 +112,21 @@ class AuthorViewModelTest {
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() =
runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) {
combine(
viewModel.authorUiState,
viewModel.newUiState,
::Pair
).collect()
}
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
val authorState = viewModel.authorUiState.value
val newsUiState = viewModel.newUiState.value
assertTrue(authorState is AuthorUiState.Success)
assertTrue(newsUiState is NewsUiState.Loading)
collectJob.cancel()
}
@ -125,21 +134,29 @@ class AuthorViewModelTest {
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) {
combine(
viewModel.authorUiState,
viewModel.newUiState,
::Pair
).collect()
}
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
val authorState = viewModel.authorUiState.value
val newsUiState = viewModel.newUiState.value
assertTrue(authorState is AuthorUiState.Success)
assertTrue(newsUiState is NewsUiState.Success)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
// Set which author IDs are followed, not including 0.
@ -149,7 +166,35 @@ class AuthorViewModelTest {
assertEquals(
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
viewModel.uiState.value.authorState
viewModel.authorUiState.value
)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenNewsBookmarked_thenShowBookmarkedNews() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.newUiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
newsRepository.sendNewsResources(sampleNewsResources)
// Set initial bookmarked status to false
userDataRepository.updateNewsResourceBookmark(
newsResourceId = sampleNewsResources.first().id,
bookmarked = false
)
viewModel.bookmarkNews(
newsResourceId = sampleNewsResources.first().id,
bookmarked = true
)
assertTrue(
(viewModel.newUiState.value as NewsUiState.Success)
.news
.first { it.newsResource.id == sampleNewsResources.first().id }
.isSaved
)
collectJob.cancel()

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

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

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

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

@ -18,11 +18,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
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.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.unit.DpSize
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.datetime.Instant
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class ForYouScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@ -63,13 +54,10 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -88,9 +76,6 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics,
@ -99,8 +84,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -110,14 +95,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertIsDisplayed()
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertIsDisplayed()
.assertExists()
.assertHasClickAction()
}
@ -129,7 +114,7 @@ class ForYouScreenTest {
composeTestRule
.onNode(doneButtonMatcher)
.assertIsDisplayed()
.assertExists()
.assertIsNotEnabled()
.assertHasClickAction()
}
@ -139,9 +124,6 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic
@ -153,8 +135,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -164,14 +146,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertIsDisplayed()
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertIsDisplayed()
.assertExists()
.assertHasClickAction()
}
@ -183,7 +165,7 @@ class ForYouScreenTest {
composeTestRule
.onNode(doneButtonMatcher)
.assertIsDisplayed()
.assertExists()
.assertIsEnabled()
.assertHasClickAction()
}
@ -193,9 +175,6 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic
@ -207,8 +186,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -218,14 +197,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertIsDisplayed()
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertIsDisplayed()
.assertExists()
.assertHasClickAction()
}
@ -237,7 +216,7 @@ class ForYouScreenTest {
composeTestRule
.onNode(doneButtonMatcher)
.assertIsDisplayed()
.assertExists()
.assertIsEnabled()
.assertHasClickAction()
}
@ -247,17 +226,14 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics,
authors = testAuthors
),
feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -286,13 +262,10 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -318,60 +291,44 @@ class ForYouScreenTest {
@Test
fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent {
BoxWithConstraints {
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
)
ForYouScreen(
windowSizeClass = windowSizeClass,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(
feed = testNewsResources
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
ForYouScreen(
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
val firstFeedItem = composeTestRule
composeTestRule
.onNodeWithText(
testNewsResources[0].newsResource.title,
previewNewsResources[0].title,
substring = true
)
.assertExists()
.assertHasClickAction()
.fetchSemanticsNode()
val secondFeedItem = composeTestRule
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
testNewsResources[1].newsResource.title,
previewNewsResources[1].title,
substring = true
)
.assertExists()
.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
),
)
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 androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
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.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
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.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
@ -54,9 +56,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.LaunchedEffect
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp
@ -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.previewNewsResources
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.TrackScrollJank
import kotlin.math.floor
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun ForYouRoute(
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel()
) {
val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle()
ForYouScreen(
windowSizeClass = windowSizeClass,
modifier = modifier,
interestsSelectionState = interestsSelectionState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ForYouScreen(
windowSizeClass: WindowSizeClass,
interestsSelectionState: ForYouInterestsSelectionUiState,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
@ -154,73 +148,63 @@ fun ForYouScreen(
},
containerColor = Color.Transparent
) { innerPadding ->
// TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed:
// https://issuetracker.google.com/issues/230514914
// https://issuetracker.google.com/issues/231320714
BoxWithConstraints(
modifier = modifier
.padding(innerPadding)
.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
// 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) {
val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope.
// Activity.reportFullyDrawn() has its own check for this
// and is safe to call multiple times though.
LaunchedEffect(Unit) {
// We're leveraging the fact, that the current view is directly set as content of Activity.
val activity = localView.context as? Activity ?: return@LaunchedEffect
// To be sure not to call in the middle of a frame draw.
localView.doOnPreDraw { activity.reportFullyDrawn() }
}
if (interestsLoaded && feedLoaded) {
val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope.
// Activity.reportFullyDrawn() has its own check for this
// and is safe to call multiple times though.
LaunchedEffect(Unit) {
// We're leveraging the fact, that the current view is directly set as content of Activity.
val activity = localView.context as? Activity ?: return@LaunchedEffect
// To be sure not to call in the middle of a frame draw.
localView.doOnPreDraw { activity.reportFullyDrawn() }
}
}
val tag = "forYou:feed"
val tag = "forYou:feed"
val lazyListState = rememberLazyListState()
TrackScrollJank(scrollableState = lazyListState, stateName = tag)
val lazyGridState = rememberLazyGridState()
TrackScrollJank(scrollableState = lazyGridState, stateName = tag)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.testTag(tag),
state = lazyListState,
) {
InterestsSelection(
interestsSelectionState = interestsSelectionState,
showLoadingUIIfLoading = true,
onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics
)
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier
.padding(innerPadding)
.consumedWindowInsets(innerPadding)
.fillMaxSize()
.testTag("forYou:feed"),
state = lazyGridState
) {
interestsSelection(
interestsSelectionState = interestsSelectionState,
onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics
)
NewsFeed(
feedState = feedState,
// Avoid showing a second loading wheel if we already are for the interests
// selection
showLoadingUIIfLoading =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading,
numberOfColumns = numberOfColumns,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
loadingContentDescription = R.string.for_you_loading
)
newsFeed(
feedState = feedState,
// Avoid showing a second loading wheel if we already are for the interests
// selection
showLoadingUIIfLoading =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
loadingContentDescription = R.string.for_you_loading
)
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
item(span = { GridItemSpan(maxLineSpan) }) {
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.
* 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
* states.
*/
private fun LazyListScope.InterestsSelection(
private fun LazyGridScope.interestsSelection(
interestsSelectionState: ForYouInterestsSelectionUiState,
showLoadingUIIfLoading: Boolean,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit
) {
when (interestsSelectionState) {
ForYouInterestsSelectionUiState.Loading -> {
if (showLoadingUIIfLoading) {
item {
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,
item(span = { GridItemSpan(maxLineSpan) }) {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = MaterialTheme.typography.titleMedium
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
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 = MaterialTheme.typography.bodyMedium
)
}
item {
AuthorsCarousel(
authors = interestsSelectionState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
item {
TopicSelection(
interestsSelectionState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
}
item {
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
NiaFilledButton(
onClick = saveFollowedTopics,
enabled = interestsSelectionState.canSaveInterests,
}
ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
item(span = { GridItemSpan(maxLineSpan) }) {
Column {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = MaterialTheme.typography.titleMedium
)
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 = MaterialTheme.typography.bodyMedium
)
AuthorsCarousel(
authors = interestsSelectionState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
.fillMaxWidth()
.padding(vertical = 8.dp)
)
TopicSelection(
interestsSelectionState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
// Done button
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.done)
)
NiaFilledButton(
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 = "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")
@ -438,7 +412,6 @@ fun ForYouScreenPopulatedFeed() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(
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 = "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")
@ -464,7 +436,6 @@ fun ForYouScreenTopicSelection() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) }
@ -474,8 +445,8 @@ fun ForYouScreenTopicSelection() {
SaveableNewsResource(it, false)
}
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
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 = "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")
@ -493,7 +463,6 @@ fun ForYouScreenLoading() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },

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

Loading…
Cancel
Save