Merge remote-tracking branch 'origin/main'

# Conflicts:
#	feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
pull/228/head
YvesKalume 2 years ago
commit b887f0c03c

@ -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

@ -70,7 +70,7 @@ The app also uses
[product flavors](https://developer.android.com/studio/build/build-variants#product-flavors) to
control where content for the app should be loaded from.
The `demo` flavor uses static local data to allow immediate building and exploring the UI.
The `demo` flavor uses static local data to allow immediate building and exploring of the UI.
The `prod` flavor makes real network calls to a backend server, providing up-to-date content. At
this time, there is not a public backend available.
@ -90,21 +90,21 @@ In tests, **Now in Android** notably does _not_ use any mocking libraries.
Instead, the production implementations can be replaced with test doubles using Hilt's testing APIs
(or via manual constructor injection for `ViewModel` tests).
These test doubles implement the same interface as the production implementations, and generally
These test doubles implement the same interface as the production implementations and generally
provide a simplified (but still realistic) implementation with additional testing hooks.
This results in less brittle tests that may exercise more production code, instead of just verifying
specific calls against mocks.
Examples:
- In instrumentation tests, a temporary folder is used to store the user's preferences, which is
wiped after reach test.
wiped after each test.
This allows using the real `DataStore` and exercising all related code, instead of mocking the
flow of data updates.
- There are `Test` implementations of each repository, which implement the normal, full repository
interface and also provide test-only hooks.
`ViewModel` tests use these `Test` repositories, and thus can use the test-only hooks to
manipulate the the state of the `Test` repository and verify the resulting behavior, instead of
manipulate the state of the `Test` repository and verify the resulting behavior, instead of
checking that specific repository methods were called.
# UI
@ -130,7 +130,7 @@ The baseline profile for this app is located at [`app/src/main/baseline-prof.txt
It contains rules that enable AOT compilation of the critical user path taken during app launch.
For more information on baseline profiles, read [this document](https://developer.android.com/studio/profile/baselineprofiles).
> Note: The baseline profile needs to be re-generated for release builds that touched code which changes app startup.
> Note: The baseline profile needs to be re-generated for release builds that touch code which changes app startup.
To generate the baseline profile, select the `benchmark` build variant and run the
`BaselineProfileGenerator` benchmark test on an AOSP Android Emulator.

@ -13,8 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.FlavorDimension
import com.google.samples.apps.nowinandroid.Flavor
import com.google.samples.apps.nowinandroid.FlavorDimension
plugins {
id("nowinandroid.android.application")
@ -115,9 +115,9 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.window.manager)
implementation(libs.material3)
implementation(libs.androidx.profileinstaller)
implementation(libs.coil.kt)

@ -27,7 +27,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Nia">
android:theme="@style/Theme.Nia.Splash">
<profileable android:shell="true" tools:targetApi="q" />
<activity

@ -21,6 +21,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.metrics.performance.JankStats
import com.google.samples.apps.nowinandroid.ui.NiaApp
@ -38,6 +39,7 @@ class MainActivity : ComponentActivity() {
lateinit var lazyStats: dagger.Lazy<JankStats>
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
// Turn off the decor fitting system windows, which allows us to handle insets,

@ -24,9 +24,6 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import java.util.concurrent.Executor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
@Module
@InstallIn(ActivityComponent::class)
@ -47,17 +44,11 @@ object JankStatsModule {
return activity.window
}
@Provides
fun providesDefaultExecutor(): Executor {
return Dispatchers.Default.asExecutor()
}
@Provides
fun providesJankStats(
window: Window,
executor: Executor,
frameListener: JankStats.OnFrameListener
): JankStats {
return JankStats.createAndTrack(window, executor, frameListener)
return JankStats.createAndTrack(window, frameListener)
}
}

@ -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)

@ -143,7 +143,7 @@ class NiaAppState(
private fun NavigationTrackingSideEffect(navController: NavHostController) {
JankMetricDisposableEffect(navController) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
metricsHolder.state?.addState("Navigation", destination.route.toString())
metricsHolder.state?.putState("Navigation", destination.route.toString())
}
navController.addOnDestinationChangedListener(listener)

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#000000"/>
<path
android:pathData="M65.08,84.13C64.01,84.13 63.13,83.26 63.13,82.18C63.13,81.11 64,80.24 65.08,80.24C66.15,80.24 67.02,81.11 67.02,82.18C67.02,83.26 66.15,84.13 65.08,84.13ZM43.6,84.13C42.53,84.13 41.65,83.26 41.65,82.18C41.65,81.11 42.52,80.24 43.6,80.24C44.66,80.24 45.54,81.11 45.54,82.18C45.54,83.26 44.67,84.13 43.6,84.13ZM65.77,72.44L69.66,65.73C69.88,65.35 69.74,64.85 69.36,64.63C68.97,64.41 68.48,64.54 68.25,64.93L64.32,71.73C61.31,70.36 57.94,69.59 54.33,69.59C50.73,69.59 47.35,70.36 44.34,71.73L40.41,64.93C40.19,64.54 39.69,64.41 39.31,64.63C38.92,64.85 38.79,65.35 39.01,65.73L42.89,72.44C36.22,76.07 31.67,82.81 31,90.77H77.67C77,82.8 72.44,76.06 65.77,72.44Z"
android:fillColor="#FCFCFC"/>
<path
android:pathData="M46.57,35H46.57C46.1,35 45.72,35.38 45.72,35.85L45.72,43.15H44.19C43.35,43.15 42.67,43.83 42.67,44.68C42.67,45.52 43.35,46.2 44.19,46.2H45.72V43.15H47.42C48.17,43.15 48.78,42.54 48.78,41.79L48.78,37.72H49.97C50.43,37.72 50.81,37.34 50.81,36.87V35.85C50.81,35.38 50.43,35 49.97,35H47.42H46.57ZM46.57,54.35H46.57H47.42H49.97C50.43,54.35 50.81,53.97 50.81,53.5V52.48C50.81,52.02 50.43,51.64 49.97,51.64H48.78L48.78,47.56C48.78,46.81 48.17,46.2 47.42,46.2H45.72L45.72,53.5C45.72,53.97 46.1,54.35 46.57,54.35ZM61.54,35H61.54C62.01,35 62.39,35.38 62.39,35.85V43.15H63.92C64.76,43.15 65.44,43.83 65.44,44.68C65.44,45.52 64.76,46.2 63.92,46.2H62.39V43.15H60.69C59.94,43.15 59.33,42.54 59.33,41.79V37.72H58.15C57.68,37.72 57.3,37.34 57.3,36.87V35.85C57.3,35.38 57.68,35 58.15,35H60.69H61.54ZM61.54,54.35H61.54H60.69H58.15C57.68,54.35 57.3,53.97 57.3,53.5V52.48C57.3,52.02 57.68,51.64 58.15,51.64H59.33V47.56C59.33,46.81 59.94,46.2 60.69,46.2H62.39V53.5C62.39,53.97 62.01,54.35 61.54,54.35Z"
android:fillColor="#FCFCFC"
android:fillType="evenOdd"/>
</vector>

@ -14,13 +14,16 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Our dark theme -->
<style name="Theme.Nia" parent="Platform.Theme.Nia">
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryDark">@color/purple_700</item>
<item name="colorAccent">@color/teal_200</item>
<style name="NightAdjusted.Theme" parent="android:Theme.Material.NoActionBar">
<item name="android:windowLightStatusBar" tools:targetApi="23">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
</style>
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar" tools:targetApi="23">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
</style>
</resources>

@ -16,8 +16,7 @@
-->
<resources>
<style name="Platform.Theme.Nia" parent="Theme.Material3.DayNight">
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
</style>
</resources>

@ -16,10 +16,8 @@
-->
<resources>
<style name="Platform.Theme.Nia" parent="Theme.Material3.DayNight">
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightNavigationBar">?attr/isLightTheme</item>
</style>
</resources>

@ -14,29 +14,32 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Allows us to override night specific attributes in the
values-night folder. -->
<style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
</style>
<!-- Allows us to override platform level specific attributes in their
respective values-vXX folder. -->
<style name="Platform.Theme.Nia" parent="Theme.Material3.DayNight">
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:statusBarColor">@color/black30</item>
</style>
<!-- The actual theme we use. This varies for light theme (here),
and values-night for dark theme. -->
<!-- TODO Change colors here and in values-night when implementing M3 theme -->
<style name="Theme.Nia" parent="Platform.Theme.Nia">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryDark">@color/purple_700</item>
<item name="colorAccent">@color/teal_200</item>
</style>
<!-- The final theme we use -->
<style name="Theme.Nia" parent="PlatformAdjusted.Theme.Nia" />
<style name="Theme.Nia.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
</style>
<style name="Theme.Nia.AppBarOverlay" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
<style name="Theme.Nia.Splash" parent="NightAdjusted.Theme.Splash">
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="postSplashScreenTheme">@style/Theme.Nia</item>
</style>
<style name="Theme.Nia.PopupOverlay" parent="ThemeOverlay.MaterialComponents.Light" />
</resources>

@ -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"

@ -26,7 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalView
import androidx.metrics.performance.PerformanceMetricsState
import androidx.metrics.performance.PerformanceMetricsState.MetricsStateHolder
import androidx.metrics.performance.PerformanceMetricsState.Holder
import kotlinx.coroutines.CoroutineScope
/**
@ -35,11 +35,11 @@ import kotlinx.coroutines.CoroutineScope
* @see PerformanceMetricsState.getForHierarchy
*/
@Composable
fun rememberMetricsStateHolder(): MetricsStateHolder {
fun rememberMetricsStateHolder(): Holder {
val localView = LocalView.current
return remember(localView) {
PerformanceMetricsState.getForHierarchy(localView)
PerformanceMetricsState.getHolderForHierarchy(localView)
}
}
@ -51,7 +51,7 @@ fun rememberMetricsStateHolder(): MetricsStateHolder {
@Composable
fun JankMetricEffect(
vararg keys: Any?,
reportMetric: suspend CoroutineScope.(state: MetricsStateHolder) -> Unit
reportMetric: suspend CoroutineScope.(state: Holder) -> Unit
) {
val metrics = rememberMetricsStateHolder()
LaunchedEffect(metrics, *keys) {
@ -66,7 +66,7 @@ fun JankMetricEffect(
@Composable
fun JankMetricDisposableEffect(
vararg keys: Any?,
reportMetric: DisposableEffectScope.(state: MetricsStateHolder) -> DisposableEffectResult
reportMetric: DisposableEffectScope.(state: Holder) -> DisposableEffectResult
) {
val metrics = rememberMetricsStateHolder()
DisposableEffect(metrics, *keys) {
@ -80,7 +80,7 @@ fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) {
snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress ->
metricsHolder.state?.apply {
if (isScrollInProgress) {
addState(stateName, "Scrolling=true")
putState(stateName, "Scrolling=true")
} else {
removeState(stateName)
}

@ -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 = { _, _ -> }
)
}

@ -183,7 +183,7 @@ Using the above modularization strategy, the Now in Android app has the followin
</td>
<td>Making network requests and handling responses from a remote data source.
</td>
<td><code>RetrofitNiANetworkApi</code>
<td><code>RetrofitNiaNetworkApi</code>
</td>
</tr>
<tr>
@ -209,7 +209,7 @@ Using the above modularization strategy, the Now in Android app has the followin
</td>
<td>Local database storage using Room.
</td>
<td><code>NiADatabase</code><br>
<td><code>NiaDatabase</code><br>
<code>DatabaseMigrations</code><br>
<code>Dao</code> classes
</td>

@ -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,17 +19,17 @@ package com.google.samples.apps.nowinandroid.feature.foryou
/**
* A sealed hierarchy for the user's current followed interests state.
*/
sealed interface FollowedInterestsState {
sealed interface FollowedInterestsUiState {
/**
* The current state is unknown (hasn't loaded yet)
*/
object Unknown : FollowedInterestsState
object Unknown : FollowedInterestsUiState
/**
* The user hasn't followed any interests yet.
*/
object None : FollowedInterestsState
object None : FollowedInterestsUiState
/**
* The user has followed the given (non-empty) set of [topicIds] or [authorIds].
@ -37,5 +37,5 @@ sealed interface FollowedInterestsState {
data class FollowedInterests(
val topicIds: Set<String>,
val authorIds: Set<String>
) : FollowedInterestsState
) : FollowedInterestsUiState
}

@ -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
@ -38,10 +39,13 @@ 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.rememberLazyListState
@ -54,9 +58,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 +71,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 +93,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,69 +150,60 @@ 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 = Modifier.height(8.dp))
@ -235,85 +222,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
.padding(horizontal = 40.dp)
.width(364.dp)
.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
.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)
)
}
}
}
}
@ -418,8 +396,8 @@ private fun SingleTopicButton(
@Composable
fun TopicIcon(
modifier: Modifier = Modifier,
imageUrl: String
imageUrl: String,
modifier: Modifier = Modifier
) {
AsyncImage(
// TODO b/228077205, show loading image visual instead of static placeholder
@ -432,7 +410,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")
@ -442,7 +419,6 @@ fun ForYouScreenPopulatedFeed() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
@ -458,7 +434,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")
@ -468,7 +443,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) }
@ -478,8 +452,8 @@ fun ForYouScreenTopicSelection() {
SaveableNewsResource(it, false)
}
),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -487,7 +461,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")
@ -497,7 +470,6 @@ fun ForYouScreenLoading() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },

@ -33,9 +33,9 @@ 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.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.FollowedInterests
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.None
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsState.Unknown
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.FollowedInterests
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.None
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.Unknown
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@ -60,7 +60,7 @@ class ForYouViewModel @Inject constructor(
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val followedInterestsState: StateFlow<FollowedInterestsState> =
private val followedInterestsUiState: StateFlow<FollowedInterestsUiState> =
userDataRepository.userDataStream
.map { userData ->
if (userData.followedAuthors.isEmpty() && userData.followedTopics.isEmpty()) {
@ -107,7 +107,7 @@ class ForYouViewModel @Inject constructor(
val feedState: StateFlow<NewsFeedUiState> =
combine(
followedInterestsState,
followedInterestsUiState,
snapshotFlow { inProgressTopicSelection },
snapshotFlow { inProgressAuthorSelection }
) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection ->
@ -148,7 +148,7 @@ class ForYouViewModel @Inject constructor(
val interestsSelectionState: StateFlow<ForYouInterestsSelectionUiState> =
combine(
followedInterestsState,
followedInterestsUiState,
topicsRepository.getTopicsStream(),
authorsRepository.getAuthorsStream(),
snapshotFlow { inProgressTopicSelection },

@ -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()
}
}

@ -49,9 +49,9 @@ import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun InterestsRoute(
modifier: Modifier = Modifier,
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -69,7 +69,7 @@ fun InterestsRoute(
)
JankMetricDisposableEffect(tabState) { metricsHolder ->
metricsHolder.state?.addState("Interests:TabState", "currentIndex:${tabState.currentIndex}")
metricsHolder.state?.putState("Interests:TabState", "currentIndex:${tabState.currentIndex}")
onDispose {
metricsHolder.state?.removeState("Interests:TabState")

@ -8,6 +8,7 @@ androidxCompose = "1.2.0-rc03"
androidxComposeCompiler = "1.2.0"
androidxComposeMaterial3 = "1.0.0-alpha13"
androidxCore = "1.8.0"
androidxCoreSplashscreen = "1.0.0"
androidxCustomView = "1.0.0-rc01"
androidxDataStore = "1.0.0"
androidxEspresso = "3.4.0"
@ -15,7 +16,7 @@ androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.0-alpha01"
androidxMacroBenchmark = "1.1.0"
androidxNavigation = "2.5.0"
androidxMetrics = "1.0.0-alpha01"
androidxMetrics = "1.0.0-alpha03"
androidxProfileinstaller = "1.2.0-rc01"
androidxSavedState = "1.1.0"
androidxStartup = "1.1.1"
@ -37,9 +38,8 @@ kotlinxSerializationJson = "1.3.3"
ksp = "1.7.0-1.0.6"
ktlint = "0.43.0"
lint = "30.2.1"
material3 = "1.6.1"
okhttp = "4.10.0"
protobuf = "3.21.1"
protobuf = "3.21.5"
protobufPlugin = "0.8.19"
retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "0.8.0"
@ -68,6 +68,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxCompose" }
androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref = "androidxCompose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" }
androidx-customview-poolingcontainer = { group = "androidx.customview", name = "customview-poolingcontainer", version.ref = "androidxCustomView"}
androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" }
androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" }
@ -108,7 +109,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = "lint" }
material3 = { group = "com.google.android.material", name = "material", version.ref = "material3" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }

Loading…
Cancel
Save