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 }} api-level: ${{ matrix.api-level }}
arch: x86_64 arch: x86_64
disable-animations: true disable-animations: true
disk-size: 1500M disk-size: 2000M
heap-size: 512M heap-size: 600M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest --stacktrace script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest --stacktrace
- name: Upload test reports - 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 [product flavors](https://developer.android.com/studio/build/build-variants#product-flavors) to
control where content for the app should be loaded from. 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 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. 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 Instead, the production implementations can be replaced with test doubles using Hilt's testing APIs
(or via manual constructor injection for `ViewModel` tests). (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. 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 This results in less brittle tests that may exercise more production code, instead of just verifying
specific calls against mocks. specific calls against mocks.
Examples: Examples:
- In instrumentation tests, a temporary folder is used to store the user's preferences, which is - 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 This allows using the real `DataStore` and exercising all related code, instead of mocking the
flow of data updates. flow of data updates.
- There are `Test` implementations of each repository, which implement the normal, full repository - There are `Test` implementations of each repository, which implement the normal, full repository
interface and also provide test-only hooks. interface and also provide test-only hooks.
`ViewModel` tests use these `Test` repositories, and thus can use the test-only hooks to `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. checking that specific repository methods were called.
# UI # 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. 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). 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 To generate the baseline profile, select the `benchmark` build variant and run the
`BaselineProfileGenerator` benchmark test on an AOSP Android Emulator. `BaselineProfileGenerator` benchmark test on an AOSP Android Emulator.

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

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

@ -21,6 +21,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.metrics.performance.JankStats import androidx.metrics.performance.JankStats
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
@ -38,6 +39,7 @@ class MainActivity : ComponentActivity() {
lateinit var lazyStats: dagger.Lazy<JankStats> lateinit var lazyStats: dagger.Lazy<JankStats>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Turn off the decor fitting system windows, which allows us to handle insets, // 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.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.components.ActivityComponent
import java.util.concurrent.Executor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
@Module @Module
@InstallIn(ActivityComponent::class) @InstallIn(ActivityComponent::class)
@ -47,17 +44,11 @@ object JankStatsModule {
return activity.window return activity.window
} }
@Provides
fun providesDefaultExecutor(): Executor {
return Dispatchers.Default.asExecutor()
}
@Provides @Provides
fun providesJankStats( fun providesJankStats(
window: Window, window: Window,
executor: Executor,
frameListener: JankStats.OnFrameListener frameListener: JankStats.OnFrameListener
): JankStats { ): JankStats {
return JankStats.createAndTrack(window, executor, frameListener) return JankStats.createAndTrack(window, frameListener)
} }
} }

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

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

@ -143,7 +143,7 @@ class NiaAppState(
private fun NavigationTrackingSideEffect(navController: NavHostController) { private fun NavigationTrackingSideEffect(navController: NavHostController) {
JankMetricDisposableEffect(navController) { metricsHolder -> JankMetricDisposableEffect(navController) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ -> val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
metricsHolder.state?.addState("Navigation", destination.route.toString()) metricsHolder.state?.putState("Navigation", destination.route.toString())
} }
navController.addOnDestinationChangedListener(listener) 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Our dark theme --> <style name="NightAdjusted.Theme" parent="android:Theme.Material.NoActionBar">
<style name="Theme.Nia" parent="Platform.Theme.Nia"> <item name="android:windowLightStatusBar" tools:targetApi="23">false</item>
<item name="colorPrimary">@color/purple_200</item> <item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
<item name="colorPrimaryDark">@color/purple_700</item> </style>
<item name="colorAccent">@color/teal_200</item>
<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> </style>
</resources> </resources>

@ -16,8 +16,7 @@
--> -->
<resources> <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:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
</style> </style>
</resources> </resources>

@ -16,10 +16,8 @@
--> -->
<resources> <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:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightNavigationBar">?attr/isLightTheme</item>
</style> </style>
</resources> </resources>

@ -14,29 +14,32 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. 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 <!-- Allows us to override platform level specific attributes in their
respective values-vXX folder. --> 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> <item name="android:statusBarColor">@color/black30</item>
</style> </style>
<!-- The actual theme we use. This varies for light theme (here), <!-- The final theme we use -->
and values-night for dark theme. --> <style name="Theme.Nia" parent="PlatformAdjusted.Theme.Nia" />
<!-- 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>
<style name="Theme.Nia.NoActionBar"> <style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="windowActionBar">false</item> <item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
<item name="windowNoTitle">true</item> <item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
</style> </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> </resources>

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

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

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

@ -21,6 +21,9 @@
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_OUT=$DIR/app/build/outputs 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 )" export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"
echo "ANDROID_HOME=$ANDROID_HOME" echo "ANDROID_HOME=$ANDROID_HOME"

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

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

@ -183,7 +183,7 @@ Using the above modularization strategy, the Now in Android app has the followin
</td> </td>
<td>Making network requests and handling responses from a remote data source. <td>Making network requests and handling responses from a remote data source.
</td> </td>
<td><code>RetrofitNiANetworkApi</code> <td><code>RetrofitNiaNetworkApi</code>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -209,7 +209,7 @@ Using the above modularization strategy, the Now in Android app has the followin
</td> </td>
<td>Local database storage using Room. <td>Local database storage using Room.
</td> </td>
<td><code>NiADatabase</code><br> <td><code>NiaDatabase</code><br>
<code>DatabaseMigrations</code><br> <code>DatabaseMigrations</code><br>
<code>Dao</code> classes <code>Dao</code> classes
</td> </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.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -52,10 +53,11 @@ class AuthorScreenTest {
fun niaLoadingWheel_whenScreenIsLoading_showLoading() { fun niaLoadingWheel_whenScreenIsLoading_showLoading() {
composeTestRule.setContent { composeTestRule.setContent {
AuthorScreen( AuthorScreen(
authorState = AuthorUiState.Loading, authorUiState = AuthorUiState.Loading,
newsState = NewsUiState.Loading, newsUiState = NewsUiState.Loading,
onBackClick = { }, onBackClick = { },
onFollowClick = { } onFollowClick = { },
onBookmarkChanged = { _, _ -> },
) )
} }
@ -69,10 +71,11 @@ class AuthorScreenTest {
val testAuthor = testAuthors.first() val testAuthor = testAuthors.first()
composeTestRule.setContent { composeTestRule.setContent {
AuthorScreen( AuthorScreen(
authorState = AuthorUiState.Success(testAuthor), authorUiState = AuthorUiState.Success(testAuthor),
newsState = NewsUiState.Loading, newsUiState = NewsUiState.Loading,
onBackClick = { }, onBackClick = { },
onFollowClick = { } onFollowClick = { },
onBookmarkChanged = { _, _ -> },
) )
} }
@ -91,10 +94,18 @@ class AuthorScreenTest {
fun news_whenAuthorIsLoading_isNotShown() { fun news_whenAuthorIsLoading_isNotShown() {
composeTestRule.setContent { composeTestRule.setContent {
AuthorScreen( AuthorScreen(
authorState = AuthorUiState.Loading, authorUiState = AuthorUiState.Loading,
newsState = NewsUiState.Success(sampleNewsResources), newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { }, onBackClick = { },
onFollowClick = { } onFollowClick = { },
onBookmarkChanged = { _, _ -> },
) )
} }
@ -103,15 +114,24 @@ class AuthorScreenTest {
.onNodeWithContentDescription(authorLoading) .onNodeWithContentDescription(authorLoading)
.assertExists() .assertExists()
} }
@Test @Test
fun news_whenSuccessAndAuthorIsSuccess_isShown() { fun news_whenSuccessAndAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first() val testAuthor = testAuthors.first()
composeTestRule.setContent { composeTestRule.setContent {
AuthorScreen( AuthorScreen(
authorState = AuthorUiState.Success(testAuthor), authorUiState = AuthorUiState.Success(testAuthor),
newsState = NewsUiState.Success(sampleNewsResources), newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { }, 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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
@ -67,24 +68,27 @@ fun AuthorRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AuthorViewModel = hiltViewModel(), 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( AuthorScreen(
authorState = uiState.authorState, authorUiState = authorUiState,
newsState = uiState.newsState, newsUiState = newsUiState,
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = viewModel::followAuthorToggle, onFollowClick = viewModel::followAuthorToggle,
onBookmarkChanged = viewModel::bookmarkNews,
) )
} }
@VisibleForTesting @VisibleForTesting
@Composable @Composable
internal fun AuthorScreen( internal fun AuthorScreen(
authorState: AuthorUiState, authorUiState: AuthorUiState,
newsState: NewsUiState, newsUiState: NewsUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit, onFollowClick: (Boolean) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@ -94,7 +98,7 @@ internal fun AuthorScreen(
item { item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
} }
when (authorState) { when (authorUiState) {
AuthorUiState.Loading -> { AuthorUiState.Loading -> {
item { item {
NiaLoadingWheel( NiaLoadingWheel(
@ -111,12 +115,13 @@ internal fun AuthorScreen(
AuthorToolbar( AuthorToolbar(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
uiState = authorState.followableAuthor, uiState = authorUiState.followableAuthor,
) )
} }
authorBody( authorBody(
author = authorState.followableAuthor.author, author = authorUiState.followableAuthor.author,
news = newsState news = newsUiState,
onBookmarkChanged = onBookmarkChanged,
) )
} }
} }
@ -128,13 +133,14 @@ internal fun AuthorScreen(
private fun LazyListScope.authorBody( private fun LazyListScope.authorBody(
author: Author, author: Author,
news: NewsUiState news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit
) { ) {
item { item {
AuthorHeader(author) AuthorHeader(author)
} }
authorCards(news) authorCards(news, onBookmarkChanged)
} }
@Composable @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) { when (news) {
is NewsUiState.Success -> { is NewsUiState.Success -> {
newsResourceCardItems( newsResourceCardItems(
items = news.news, items = news.news,
newsResourceMapper = { it }, newsResourceMapper = { it.newsResource },
isBookmarkedMapper = { /* TODO */ false }, isBookmarkedMapper = { it.isSaved },
onToggleBookmark = { /* TODO */ }, onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) },
itemModifier = Modifier.padding(24.dp) itemModifier = Modifier.padding(24.dp)
) )
} }
@ -227,10 +236,18 @@ fun AuthorScreenPopulated() {
NiaTheme { NiaTheme {
NiaBackground { NiaBackground {
AuthorScreen( AuthorScreen(
authorState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)), authorUiState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)),
newsState = NewsUiState.Success(previewNewsResources), newsUiState = NewsUiState.Success(
previewNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = {}, onBackClick = {},
onFollowClick = {} onFollowClick = {},
onBookmarkChanged = { _, _ -> },
) )
} }
} }
@ -245,10 +262,11 @@ fun AuthorScreenLoading() {
NiaTheme { NiaTheme {
NiaBackground { NiaBackground {
AuthorScreen( AuthorScreen(
authorState = AuthorUiState.Loading, authorUiState = AuthorUiState.Loading,
newsState = NewsUiState.Loading, newsUiState = NewsUiState.Loading,
onBackClick = {}, 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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.Result
import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
@ -50,66 +51,126 @@ class AuthorViewModel @Inject constructor(
savedStateHandle[AuthorDestination.authorIdArg] 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. // Observe the followed authors, as they could change over time.
private val followedAuthorIdsStream: Flow<Result<Set<String>>> = val followedAuthorIdsStream: Flow<Set<String>> =
userDataRepository.userDataStream userDataRepository.userDataStream
.map { it.followedAuthors } .map { it.followedAuthors }
.asResult()
// Observe author information // Observe author information
private val author: Flow<Result<Author>> = authorsRepository.getAuthorStream( val authorStream: Flow<Author> = authorsRepository.getAuthorStream(
id = authorId id = authorId
).asResult() )
// Observe the News for this author return combine(
private val newsStream: Flow<Result<List<NewsResource>>> = followedAuthorIdsStream,
newsRepository.getNewsResourcesStream( authorStream,
filterAuthorIds = setOf(element = authorId), ::Pair
filterTopicIds = emptySet() )
).asResult() .asResult()
.map { followedAuthorToAuthorResult ->
val uiState: StateFlow<AuthorScreenUiState> = when (followedAuthorToAuthorResult) {
combine( is Result.Success -> {
followedAuthorIdsStream, val (followedAuthors, author) = followedAuthorToAuthorResult.data
author, val followed = followedAuthors.contains(authorId)
newsStream
) { followedAuthorsResult, authorResult, newsResult ->
val author: AuthorUiState =
if (authorResult is Result.Success && followedAuthorsResult is Result.Success) {
val followed = followedAuthorsResult.data.contains(authorId)
AuthorUiState.Success( AuthorUiState.Success(
followableAuthor = FollowableAuthor( followableAuthor = FollowableAuthor(
author = authorResult.data, author = author,
isFollowed = followed isFollowed = followed
) )
) )
} else if ( }
authorResult is Result.Loading || followedAuthorsResult is Result.Loading is Result.Loading -> {
) {
AuthorUiState.Loading AuthorUiState.Loading
} else { }
is Result.Error -> {
AuthorUiState.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) { private fun newsUiStateStream(
viewModelScope.launch { authorId: String,
userDataRepository.toggleFollowedAuthorId(authorId, followed) 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 { sealed interface AuthorUiState {
@ -119,12 +180,7 @@ sealed interface AuthorUiState {
} }
sealed interface NewsUiState { sealed interface NewsUiState {
data class Success(val news: List<NewsResource>) : NewsUiState data class Success(val news: List<SaveableNewsResource>) : NewsUiState
object Error : NewsUiState object Error : NewsUiState
object Loading : 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.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -68,16 +69,16 @@ class AuthorViewModelTest {
@Test @Test
fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest { 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 // To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author)) authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = viewModel.uiState.value val item = viewModel.authorUiState.value
assertTrue(item.authorState is AuthorUiState.Success) assertTrue(item is AuthorUiState.Success)
val successAuthorUiState = item.authorState as AuthorUiState.Success val successAuthorUiState = item as AuthorUiState.Success
val authorFromRepository = authorsRepository.getAuthorStream( val authorFromRepository = authorsRepository.getAuthorStream(
id = testInputAuthors[0].author.id id = testInputAuthors[0].author.id
).first() ).first()
@ -90,20 +91,20 @@ class AuthorViewModelTest {
@Test @Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest { fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState) assertEquals(NewsUiState.Loading, viewModel.newUiState.value)
} }
@Test @Test
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest { fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState) assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value)
} }
@Test @Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest { 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)) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState) assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value)
collectJob.cancel() collectJob.cancel()
} }
@ -111,13 +112,21 @@ class AuthorViewModelTest {
@Test @Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() = fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() =
runTest { 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 }) authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = viewModel.uiState.value val authorState = viewModel.authorUiState.value
assertTrue(item.authorState is AuthorUiState.Success) val newsUiState = viewModel.newUiState.value
assertTrue(item.newsState is NewsUiState.Loading)
assertTrue(authorState is AuthorUiState.Success)
assertTrue(newsUiState is NewsUiState.Loading)
collectJob.cancel() collectJob.cancel()
} }
@ -125,21 +134,29 @@ class AuthorViewModelTest {
@Test @Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() = fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest { 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 }) authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
val item = viewModel.uiState.value val authorState = viewModel.authorUiState.value
assertTrue(item.authorState is AuthorUiState.Success) val newsUiState = viewModel.newUiState.value
assertTrue(item.newsState is NewsUiState.Success)
assertTrue(authorState is AuthorUiState.Success)
assertTrue(newsUiState is NewsUiState.Success)
collectJob.cancel() collectJob.cancel()
} }
@Test @Test
fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest { 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 }) authorsRepository.sendAuthors(testInputAuthors.map { it.author })
// Set which author IDs are followed, not including 0. // Set which author IDs are followed, not including 0.
@ -149,7 +166,35 @@ class AuthorViewModelTest {
assertEquals( assertEquals(
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]), 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() collectJob.cancel()

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

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

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

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

@ -18,11 +18,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasContentDescription
@ -33,21 +29,16 @@ import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.unit.DpSize
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.datetime.Instant
import org.junit.Assert
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class ForYouScreenTest { class ForYouScreenTest {
@get:Rule @get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>() val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@ -63,13 +54,10 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -88,9 +76,6 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics, topics = testTopics,
@ -99,8 +84,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -110,14 +95,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor -> testAuthors.forEach { testAuthor ->
composeTestRule composeTestRule
.onNodeWithText(testAuthor.author.name) .onNodeWithText(testAuthor.author.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
@ -129,7 +114,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNode(doneButtonMatcher) .onNode(doneButtonMatcher)
.assertIsDisplayed() .assertExists()
.assertIsNotEnabled() .assertIsNotEnabled()
.assertHasClickAction() .assertHasClickAction()
} }
@ -139,9 +124,6 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic // Follow one topic
@ -153,8 +135,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -164,14 +146,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor -> testAuthors.forEach { testAuthor ->
composeTestRule composeTestRule
.onNodeWithText(testAuthor.author.name) .onNodeWithText(testAuthor.author.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
@ -183,7 +165,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNode(doneButtonMatcher) .onNode(doneButtonMatcher)
.assertIsDisplayed() .assertExists()
.assertIsEnabled() .assertIsEnabled()
.assertHasClickAction() .assertHasClickAction()
} }
@ -193,9 +175,6 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic // Follow one topic
@ -207,8 +186,8 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -218,14 +197,14 @@ class ForYouScreenTest {
testAuthors.forEach { testAuthor -> testAuthors.forEach { testAuthor ->
composeTestRule composeTestRule
.onNodeWithText(testAuthor.author.name) .onNodeWithText(testAuthor.author.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
.assertIsDisplayed() .assertExists()
.assertHasClickAction() .assertHasClickAction()
} }
@ -237,7 +216,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNode(doneButtonMatcher) .onNode(doneButtonMatcher)
.assertIsDisplayed() .assertExists()
.assertIsEnabled() .assertIsEnabled()
.assertHasClickAction() .assertHasClickAction()
} }
@ -247,17 +226,14 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics, topics = testTopics,
authors = testAuthors authors = testAuthors
), ),
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -286,13 +262,10 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -318,60 +291,44 @@ class ForYouScreenTest {
@Test @Test
fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() { fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize( interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
DpSize(maxWidth, maxHeight) feedState = NewsFeedUiState.Success(
) feed = previewNewsResources.map {
SaveableNewsResource(it, false)
ForYouScreen( }
windowSizeClass = windowSizeClass, ),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, onTopicCheckedChanged = { _, _ -> },
feedState = NewsFeedUiState.Success( onAuthorCheckedChanged = { _, _ -> },
feed = testNewsResources saveFollowedTopics = {},
), onNewsResourcesCheckedChanged = { _, _ -> }
onAuthorCheckedChanged = { _, _ -> }, )
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
} }
val firstFeedItem = composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
testNewsResources[0].newsResource.title, previewNewsResources[0].title,
substring = true substring = true
) )
.assertExists()
.assertHasClickAction() .assertHasClickAction()
.fetchSemanticsNode()
val secondFeedItem = composeTestRule composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText( .onNodeWithText(
testNewsResources[1].newsResource.title, previewNewsResources[1].title,
substring = true substring = true
) )
.assertExists()
.assertHasClickAction() .assertHasClickAction()
.fetchSemanticsNode()
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> {
// On smaller screen widths, the second feed item should be below the first because
// they are displayed in a single column
Assert.assertTrue(
firstFeedItem.positionInRoot.y < secondFeedItem.positionInRoot.y
)
}
else -> {
// On larger screen widths, the second feed item should be inline with the first
// because they are displayed in more than one column
Assert.assertTrue(
firstFeedItem.positionInRoot.y == secondFeedItem.positionInRoot.y
)
}
}
} }
} }
@ -415,71 +372,3 @@ private val testAuthors = listOf(
isFollowed = false isFollowed = false
), ),
) )
private val testNewsResources = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = "1",
episodeId = "52",
title = "Small Title",
content = "small.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = null,
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = emptyList(),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "2",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "3",
episodeId = "52",
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
)

@ -19,17 +19,17 @@ package com.google.samples.apps.nowinandroid.feature.foryou
/** /**
* A sealed hierarchy for the user's current followed interests state. * 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) * The current state is unknown (hasn't loaded yet)
*/ */
object Unknown : FollowedInterestsState object Unknown : FollowedInterestsUiState
/** /**
* The user hasn't followed any interests yet. * 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]. * The user has followed the given (non-empty) set of [topicIds] or [authorIds].
@ -37,5 +37,5 @@ sealed interface FollowedInterestsState {
data class FollowedInterests( data class FollowedInterests(
val topicIds: Set<String>, val topicIds: Set<String>,
val authorIds: 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 android.app.Activity
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -38,10 +39,13 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -54,9 +58,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -70,7 +71,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -93,36 +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.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.NewsFeed
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import kotlin.math.floor import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class) @OptIn(ExperimentalLifecycleComposeApi::class)
@Composable @Composable
fun ForYouRoute( fun ForYouRoute(
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel() viewModel: ForYouViewModel = hiltViewModel()
) { ) {
val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle() val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle()
ForYouScreen( ForYouScreen(
windowSizeClass = windowSizeClass,
modifier = modifier,
interestsSelectionState = interestsSelectionState, interestsSelectionState = interestsSelectionState,
feedState = feedState, feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection, onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests, saveFollowedTopics = viewModel::saveFollowedInterests,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier
) )
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ForYouScreen( fun ForYouScreen(
windowSizeClass: WindowSizeClass,
interestsSelectionState: ForYouInterestsSelectionUiState, interestsSelectionState: ForYouInterestsSelectionUiState,
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
@ -154,69 +150,60 @@ fun ForYouScreen(
}, },
containerColor = Color.Transparent containerColor = Color.Transparent
) { innerPadding -> ) { innerPadding ->
// TODO: Replace with `LazyVerticalGrid` when blocking bugs are fixed: // Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// https://issuetracker.google.com/issues/230514914 // This code should be called when the UI is ready for use
// https://issuetracker.google.com/issues/231320714 // and relates to Time To Full Display.
BoxWithConstraints( val interestsLoaded =
modifier = modifier interestsSelectionState !is ForYouInterestsSelectionUiState.Loading
.padding(innerPadding) val feedLoaded = feedState !is NewsFeedUiState.Loading
.consumedWindowInsets(innerPadding)
) {
val numberOfColumns = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> 1
else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1)
}
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// This code should be called when the UI is ready for use
// and relates to Time To Full Display.
val interestsLoaded =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading
val feedLoaded = feedState !is NewsFeedUiState.Loading
if (interestsLoaded && feedLoaded) { if (interestsLoaded && feedLoaded) {
val localView = LocalView.current val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition, // We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope. // however it will be called again if this composable goes out of scope.
// Activity.reportFullyDrawn() has its own check for this // Activity.reportFullyDrawn() has its own check for this
// and is safe to call multiple times though. // and is safe to call multiple times though.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// We're leveraging the fact, that the current view is directly set as content of Activity. // We're leveraging the fact, that the current view is directly set as content of Activity.
val activity = localView.context as? Activity ?: return@LaunchedEffect val activity = localView.context as? Activity ?: return@LaunchedEffect
// To be sure not to call in the middle of a frame draw. // To be sure not to call in the middle of a frame draw.
localView.doOnPreDraw { activity.reportFullyDrawn() } localView.doOnPreDraw { activity.reportFullyDrawn() }
}
} }
}
val tag = "forYou:feed" val tag = "forYou:feed"
val lazyListState = rememberLazyListState() val lazyGridState = rememberLazyGridState()
TrackScrollJank(scrollableState = lazyListState, stateName = tag) TrackScrollJank(scrollableState = lazyGridState, stateName = tag)
LazyColumn( LazyVerticalGrid(
modifier = Modifier columns = Adaptive(300.dp),
.fillMaxSize() contentPadding = PaddingValues(16.dp),
.testTag(tag), horizontalArrangement = Arrangement.spacedBy(32.dp),
state = lazyListState, verticalArrangement = Arrangement.spacedBy(24.dp),
) { modifier = modifier
InterestsSelection( .padding(innerPadding)
interestsSelectionState = interestsSelectionState, .consumedWindowInsets(innerPadding)
showLoadingUIIfLoading = true, .fillMaxSize()
onAuthorCheckedChanged = onAuthorCheckedChanged, .testTag("forYou:feed"),
onTopicCheckedChanged = onTopicCheckedChanged, state = lazyGridState
saveFollowedTopics = saveFollowedTopics ) {
) interestsSelection(
interestsSelectionState = interestsSelectionState,
onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics
)
NewsFeed( newsFeed(
feedState = feedState, feedState = feedState,
// Avoid showing a second loading wheel if we already are for the interests // Avoid showing a second loading wheel if we already are for the interests
// selection // selection
showLoadingUIIfLoading = showLoadingUIIfLoading =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading, interestsSelectionState !is ForYouInterestsSelectionUiState.Loading,
numberOfColumns = numberOfColumns, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, loadingContentDescription = R.string.for_you_loading
loadingContentDescription = R.string.for_you_loading )
)
item { item {
Spacer(modifier = Modifier.height(8.dp)) 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. * An extension on [LazyListScope] defining the interests selection portion of the for you screen.
* Depending on the [interestsSelectionState], this might emit no items. * Depending on the [interestsSelectionState], this might emit no items.
* *
* @param showLoadingUIIfLoading if true, show a visual indication of loading if the * @param showLoaderWhenLoading if true, show a visual indication of loading if the
* [interestsSelectionState] is loading. This is controllable to permit du-duplicating loading * [interestsSelectionState] is loading. This is controllable to permit du-duplicating loading
* states. * states.
*/ */
private fun LazyListScope.InterestsSelection( private fun LazyGridScope.interestsSelection(
interestsSelectionState: ForYouInterestsSelectionUiState, interestsSelectionState: ForYouInterestsSelectionUiState,
showLoadingUIIfLoading: Boolean,
onAuthorCheckedChanged: (String, Boolean) -> Unit, onAuthorCheckedChanged: (String, Boolean) -> Unit,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit saveFollowedTopics: () -> Unit
) { ) {
when (interestsSelectionState) { when (interestsSelectionState) {
ForYouInterestsSelectionUiState.Loading -> { ForYouInterestsSelectionUiState.Loading -> {
if (showLoadingUIIfLoading) { item(span = { GridItemSpan(maxLineSpan) }) {
item { NiaLoadingWheel(
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
}
ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
item {
Text(
text = stringResource(R.string.onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 24.dp), .wrapContentSize()
style = MaterialTheme.typography.titleMedium .testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
) )
} }
item { }
Text( ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
text = stringResource(R.string.onboarding_guidance_subtitle), is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
modifier = Modifier item(span = { GridItemSpan(maxLineSpan) }) {
.fillMaxWidth() Column {
.padding(top = 8.dp, start = 16.dp, end = 16.dp), Text(
textAlign = TextAlign.Center, text = stringResource(R.string.onboarding_guidance_title),
style = MaterialTheme.typography.bodyMedium textAlign = TextAlign.Center,
) modifier = Modifier
} .fillMaxWidth()
item { .padding(top = 24.dp),
AuthorsCarousel( style = MaterialTheme.typography.titleMedium
authors = interestsSelectionState.authors, )
onAuthorClick = onAuthorCheckedChanged, Text(
modifier = Modifier text = stringResource(R.string.onboarding_guidance_subtitle),
.fillMaxWidth()
.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,
modifier = Modifier modifier = Modifier
.padding(horizontal = 40.dp) .fillMaxWidth()
.width(364.dp) .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( NiaFilledButton(
text = stringResource(R.string.done) onClick = saveFollowedTopics,
) enabled = interestsSelectionState.canSaveInterests,
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
) {
Text(
text = stringResource(R.string.done)
)
}
} }
} }
} }
@ -418,8 +396,8 @@ private fun SingleTopicButton(
@Composable @Composable
fun TopicIcon( fun TopicIcon(
modifier: Modifier = Modifier, imageUrl: String,
imageUrl: String modifier: Modifier = Modifier
) { ) {
AsyncImage( AsyncImage(
// TODO b/228077205, show loading image visual instead of static placeholder // 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 = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@ -442,7 +419,6 @@ fun ForYouScreenPopulatedFeed() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
@ -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 = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@ -468,7 +443,6 @@ fun ForYouScreenTopicSelection() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = previewTopics.map { FollowableTopic(it, false) }, topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) } authors = previewAuthors.map { FollowableAuthor(it, false) }
@ -478,8 +452,8 @@ fun ForYouScreenTopicSelection() {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)
} }
), ),
onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } 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 = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@ -497,7 +470,6 @@ fun ForYouScreenLoading() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },

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

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

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

@ -8,6 +8,7 @@ androidxCompose = "1.2.0-rc03"
androidxComposeCompiler = "1.2.0" androidxComposeCompiler = "1.2.0"
androidxComposeMaterial3 = "1.0.0-alpha13" androidxComposeMaterial3 = "1.0.0-alpha13"
androidxCore = "1.8.0" androidxCore = "1.8.0"
androidxCoreSplashscreen = "1.0.0"
androidxCustomView = "1.0.0-rc01" androidxCustomView = "1.0.0-rc01"
androidxDataStore = "1.0.0" androidxDataStore = "1.0.0"
androidxEspresso = "3.4.0" androidxEspresso = "3.4.0"
@ -15,7 +16,7 @@ androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.0-alpha01" androidxLifecycle = "2.6.0-alpha01"
androidxMacroBenchmark = "1.1.0" androidxMacroBenchmark = "1.1.0"
androidxNavigation = "2.5.0" androidxNavigation = "2.5.0"
androidxMetrics = "1.0.0-alpha01" androidxMetrics = "1.0.0-alpha03"
androidxProfileinstaller = "1.2.0-rc01" androidxProfileinstaller = "1.2.0-rc01"
androidxSavedState = "1.1.0" androidxSavedState = "1.1.0"
androidxStartup = "1.1.1" androidxStartup = "1.1.1"
@ -37,9 +38,8 @@ kotlinxSerializationJson = "1.3.3"
ksp = "1.7.0-1.0.6" ksp = "1.7.0-1.0.6"
ktlint = "0.43.0" ktlint = "0.43.0"
lint = "30.2.1" lint = "30.2.1"
material3 = "1.6.1"
okhttp = "4.10.0" okhttp = "4.10.0"
protobuf = "3.21.1" protobuf = "3.21.5"
protobufPlugin = "0.8.19" protobufPlugin = "0.8.19"
retrofit = "2.9.0" retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "0.8.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-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-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-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-customview-poolingcontainer = { group = "androidx.customview", name = "customview-poolingcontainer", version.ref = "androidxCustomView"}
androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" }
androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", 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-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" } 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" } 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" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } 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" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }

Loading…
Cancel
Save