Merge branch 'main' into takahirom/rename-topicBody/2023-07-13

pull/834/head
Milosz Moczkowski 1 year ago committed by GitHub
commit 89a1294c63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -15,7 +15,7 @@ follows Android design and development best practices and is intended to be a us
for developers. As a running app, it's intended to help developers keep up-to-date with the world for developers. As a running app, it's intended to help developers keep up-to-date with the world
of Android development by providing regular news updates. of Android development by providing regular news updates.
The app is currently in development. The `demoRelease` variant is [available on the Play Store in open beta](https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid). The app is currently in development. The `prodRelease` variant is [available on the Play Store](https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid).
# Features # Features
@ -154,8 +154,8 @@ Run the following command to get and analyse compose compiler metrics:
./gradlew assembleRelease -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true ./gradlew assembleRelease -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true
``` ```
The reports files will be added to build/compose-reports in each module. The metrics files will be The reports files will be added to [build/compose-reports](build/compose-reports). The metrics files will also be
added to build/compose-metrics in each module. added to [build/compose-metrics](build/compose-metrics).
For more information on Compose compiler metrics, see [this blog post](https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8). For more information on Compose compiler metrics, see [this blog post](https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8).

@ -29,8 +29,8 @@ plugins {
android { android {
defaultConfig { defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid" applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 5 versionCode = 8
versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level versionName = "0.1.2" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph // Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
@ -106,7 +106,6 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.testManifest) debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(project(":ui-test-hilt-manifest")) debugImplementation(project(":ui-test-hilt-manifest"))
implementation(libs.accompanist.systemuicontroller)
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)
@ -122,12 +121,3 @@ dependencies {
implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt) implementation(libs.coil.kt)
} }
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13
configurations.configureEach {
resolutionStrategy {
force(libs.junit4)
// Temporary workaround for https://issuetracker.google.com/174733673
force("org.objenesis:objenesis:2.6")
}
}

@ -7,3 +7,13 @@
-dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE -dontwarn org.openjsse.net.ssl.OpenJSSE
# Fix for Retrofit issue https://github.com/square/retrofit/issues/3751
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

@ -27,19 +27,27 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
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.test.espresso.Espresso import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty import kotlin.properties.ReadOnlyProperty
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
@ -78,6 +86,9 @@ class NavigationTest {
@get:Rule(order = 3) @get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<MainActivity>() val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var topicsRepository: TopicsRepository
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) } ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) }
@ -92,6 +103,9 @@ class NavigationTest {
private val brand by composeTestRule.stringResource(SettingsR.string.brand_android) private val brand by composeTestRule.stringResource(SettingsR.string.brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text) private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text)
@Before
fun setup() = hiltRule.inject()
@Test @Test
fun firstScreen_isForYou() { fun firstScreen_isForYou() {
composeTestRule.apply { composeTestRule.apply {
@ -251,11 +265,14 @@ class NavigationTest {
} }
@Test @Test
fun navigationBar_multipleBackStackInterests() { fun navigationBar_multipleBackStackInterests() = runTest {
composeTestRule.apply { composeTestRule.apply {
onNodeWithText(interests).performClick() onNodeWithText(interests).performClick()
// TODO: Grab string from fake data
onNodeWithText("Android Studio & Tools").performClick() // Select the last topic
val topic = topicsRepository.getTopics().first().sortedBy(Topic::name).last().name
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic))
onNodeWithText(topic).performClick()
// Switch tab // Switch tab
onNodeWithText(forYou).performClick() onNodeWithText(forYou).performClick()
@ -264,7 +281,7 @@ class NavigationTest {
onNodeWithText(interests).performClick() onNodeWithText(interests).performClick()
// Verify we're not in the list of interests // Verify we're not in the list of interests
onNodeWithText("Android Auto").assertDoesNotExist() // TODO: Grab string from fake data onNodeWithTag("interests:topics").assertDoesNotExist()
} }
} }
} }

@ -183,7 +183,7 @@ class NiaAppStateTest {
@Composable @Composable
private fun rememberTestNavController(): TestNavHostController { private fun rememberTestNavController(): TestNavHostController {
val context = LocalContext.current val context = LocalContext.current
val navController = remember { return remember<TestNavHostController> {
TestNavHostController(context).apply { TestNavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator()) navigatorProvider.addNavigator(ComposeNavigator())
graph = createGraph(startDestination = "a") { graph = createGraph(startDestination = "a") {
@ -193,5 +193,4 @@ private fun rememberTestNavController(): TestNavHostController {
} }
} }
} }
return navController
} }

@ -19,7 +19,9 @@ package com.google.samples.apps.nowinandroid
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
@ -31,13 +33,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats import androidx.metrics.performance.JankStats
import androidx.profileinstaller.ProfileVerifier import androidx.profileinstaller.ProfileVerifier
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
@ -108,16 +108,28 @@ class MainActivity : ComponentActivity() {
} }
// 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,
// including IME animations // including IME animations, and go edge-to-edge
WindowCompat.setDecorFitsSystemWindows(window, false) // This also sets up the initial system bar style based on the platform theme
enableEdgeToEdge()
setContent { setContent {
val systemUiController = rememberSystemUiController()
val darkTheme = shouldUseDarkTheme(uiState) val darkTheme = shouldUseDarkTheme(uiState)
// Update the dark content of the system bars to match the theme // Update the edge to edge configuration to match the theme
DisposableEffect(systemUiController, darkTheme) { // This is the same parameters as the default enableEdgeToEdge call, but we manually
systemUiController.systemBarsDarkContentEnabled = !darkTheme // resolve whether or not to show dark theme using uiState, since it can be different
// than the configuration's dark theme value based on the user preference.
DisposableEffect(darkTheme) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT,
) { darkTheme },
navigationBarStyle = SystemBarStyle.auto(
lightScrim,
darkScrim,
) { darkTheme },
)
onDispose {} onDispose {}
} }
@ -224,3 +236,15 @@ private fun shouldUseDarkTheme(
DarkThemeConfig.DARK -> true DarkThemeConfig.DARK -> true
} }
} }
/**
* The default light scrim, as defined by androidx and the platform:
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598
*/
private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
/**
* The default dark scrim, as defined by androidx and the platform:
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598
*/
private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b)

@ -43,6 +43,6 @@ class MainActivityViewModel @Inject constructor(
} }
sealed interface MainActivityUiState { sealed interface MainActivityUiState {
object Loading : MainActivityUiState data object Loading : MainActivityUiState
data class Success(val userData: UserData) : MainActivityUiState data class Success(val userData: UserData) : MainActivityUiState
} }

@ -16,10 +16,7 @@
--> -->
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.NoActionBar"> <style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.NoActionBar" />
<item name="android:windowLightStatusBar" tools:targetApi="23">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
</style>
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen"> <style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar" tools:targetApi="23">false</item> <item name="android:windowLightStatusBar" tools:targetApi="23">false</item>

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2021 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.
-->
<resources>
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2021 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.
-->
<resources>
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

@ -18,19 +18,10 @@
<!-- Allows us to override night specific attributes in the <!-- Allows us to override night specific attributes in the
values-night folder. --> values-night folder. -->
<style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.Light.NoActionBar"> <style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.Light.NoActionBar" />
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
</style>
<!-- Allows us to override platform level specific attributes in their
respective values-vXX folder. -->
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:statusBarColor">@color/black30</item>
</style>
<!-- The final theme we use --> <!-- The final theme we use -->
<style name="Theme.Nia" parent="PlatformAdjusted.Theme.Nia" /> <style name="Theme.Nia" parent="NightAdjusted.Theme.Nia" />
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen"> <style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item> <item name="android:windowLightStatusBar" tools:targetApi="23">true</item>

@ -14,4 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" /> <manifest />

@ -16,10 +16,13 @@
package com.google.samples.apps.nowinandroid package com.google.samples.apps.nowinandroid
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Direction import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.benchmarks.BuildConfig import com.google.samples.apps.nowinandroid.benchmarks.BuildConfig
import java.io.ByteArrayOutputStream
/** /**
* Convenience parameter to use proper package name with regards to build type and build flavor. * Convenience parameter to use proper package name with regards to build type and build flavor.
@ -38,3 +41,24 @@ fun UiDevice.flingElementDownUp(element: UiObject2) {
waitForIdle() waitForIdle()
element.fling(Direction.UP) element.fling(Direction.UP)
} }
/**
* Waits until an object with [selector] if visible on screen and returns the object.
* If the element is not available in [timeout], throws [AssertionError]
*/
fun UiDevice.waitAndFindObject(selector: BySelector, timeout: Long): UiObject2 {
if (!wait(Until.hasObject(selector), timeout)) {
throw AssertionError("Element not found on screen in ${timeout}ms (selector=$selector)")
}
return findObject(selector)
}
/**
* Helper to dump window hierarchy into a string.
*/
fun UiDevice.dumpWindowHierarchy(): String {
val buffer = ByteArrayOutputStream()
dumpWindowHierarchy(buffer)
return buffer.toString()
}

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications
import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics
@ -39,7 +40,7 @@ class BaselineProfileGenerator {
// This block defines the app's critical user journey. Here we are interested in // This block defines the app's critical user journey. Here we are interested in
// optimizing for app startup. But you can also navigate and scroll // optimizing for app startup. But you can also navigate and scroll
// through your most important UI. // through your most important UI.
allowNotifications()
pressHome() pressHome()
startActivityAndWait() startActivityAndWait()

@ -21,13 +21,14 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import androidx.test.uiautomator.untilHasChildren import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
import com.google.samples.apps.nowinandroid.waitAndFindObject
fun MacrobenchmarkScope.forYouWaitForContent() { fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded by checking if topics are loaded // Wait until content is loaded by checking if topics are loaded
device.wait(Until.gone(By.res("loadingWheel")), 5_000) device.wait(Until.gone(By.res("loadingWheel")), 5_000)
// Sometimes, the loading wheel is gone, but the content is not loaded yet // Sometimes, the loading wheel is gone, but the content is not loaded yet
// So we'll wait here for topics to be sure // So we'll wait here for topics to be sure
val obj = device.findObject(By.res("forYou:topicSelection")) val obj = device.waitAndFindObject(By.res("forYou:topicSelection"), 10_000)
// Timeout here is quite big, because sometimes data loading takes a long time! // Timeout here is quite big, because sometimes data loading takes a long time!
obj.wait(untilHasChildren(), 60_000) obj.wait(untilHasChildren(), 60_000)
} }
@ -88,3 +89,17 @@ fun MacrobenchmarkScope.forYouScrollFeedDownUp() {
val feedList = device.findObject(By.res("forYou:feed")) val feedList = device.findObject(By.res("forYou:feed"))
device.flingElementDownUp(feedList) device.flingElementDownUp(feedList)
} }
fun MacrobenchmarkScope.setAppTheme(isDark: Boolean) {
when (isDark) {
true -> device.findObject(By.text("Dark")).click()
false -> device.findObject(By.text("Light")).click()
}
device.waitForIdle()
device.findObject(By.text("OK")).click()
// Wait until the top app bar is visible on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000)
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Now in Android")), 2_000)
}

@ -43,7 +43,7 @@ class ScrollForYouFeedBenchmark {
metrics = listOf(FrameTimingMetric()), metrics = listOf(FrameTimingMetric()),
compilationMode = compilationMode, compilationMode = compilationMode,
iterations = 10, iterations = 10,
startupMode = StartupMode.COLD, startupMode = StartupMode.WARM,
setupBlock = { setupBlock = {
// Start the app // Start the app
pressHome() pressHome()

@ -0,0 +1,83 @@
/*
* Copyright 2023 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.interests
import android.os.Build.VERSION_CODES
import androidx.annotation.RequiresApi
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.PowerCategory
import androidx.benchmark.macro.PowerCategoryDisplayLevel
import androidx.benchmark.macro.PowerMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.foryou.setAppTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(ExperimentalMetricApi::class)
@RequiresApi(VERSION_CODES.Q)
@RunWith(AndroidJUnit4::class)
class ScrollTopicListPowerMetricsBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
private val categories = PowerCategory.values()
.associateWith { PowerCategoryDisplayLevel.TOTAL }
@Test
fun benchmarkStateChangeCompilationLight() =
benchmarkStateChangeWithTheme(CompilationMode.Partial(), false)
@Test
fun benchmarkStateChangeCompilationDark() =
benchmarkStateChangeWithTheme(CompilationMode.Partial(), true)
private fun benchmarkStateChangeWithTheme(compilationMode: CompilationMode, isDark: Boolean) =
benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(FrameTimingMetric(), PowerMetric(PowerMetric.Energy(categories))),
compilationMode = compilationMode,
iterations = 2,
startupMode = StartupMode.WARM,
setupBlock = {
// Start the app
pressHome()
startActivityAndWait()
allowNotifications()
// Navigate to Settings
device.findObject(By.desc("Settings")).click()
device.waitForIdle()
setAppTheme(isDark)
},
) {
forYouWaitForContent()
forYouSelectTopics()
repeat(3) {
forYouScrollFeedDownUp()
}
}
}

@ -19,45 +19,24 @@ package com.google.samples.apps.nowinandroid.startup
import androidx.benchmark.macro.BaselineProfileMode.Disable import androidx.benchmark.macro.BaselineProfileMode.Disable
import androidx.benchmark.macro.BaselineProfileMode.Require import androidx.benchmark.macro.BaselineProfileMode.Require
import androidx.benchmark.macro.CompilationMode import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupMode.COLD import androidx.benchmark.macro.StartupMode.COLD
import androidx.benchmark.macro.StartupMode.HOT
import androidx.benchmark.macro.StartupMode.WARM
import androidx.benchmark.macro.StartupTimingMetric import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
/** /**
* Enables app startups from various states of baseline profile or [CompilationMode]s.
* Run this benchmark from Studio to see startup measurements, and captured system traces * Run this benchmark from Studio to see startup measurements, and captured system traces
* for investigating your app's performance from a cold state. * for investigating your app's performance from a cold state.
*/ */
@RunWith(AndroidJUnit4ClassRunner::class) @RunWith(AndroidJUnit4ClassRunner::class)
class ColdStartupBenchmark : AbstractStartupBenchmark(COLD) class StartupBenchmark {
/**
* Run this benchmark from Studio to see startup measurements, and captured system traces
* for investigating your app's performance from a warm state.
*/
@RunWith(AndroidJUnit4ClassRunner::class)
class WarmStartupBenchmark : AbstractStartupBenchmark(WARM)
/**
* Run this benchmark from Studio to see startup measurements, and captured system traces
* for investigating your app's performance from a hot state.
*/
@RunWith(AndroidJUnit4ClassRunner::class)
class HotStartupBenchmark : AbstractStartupBenchmark(HOT)
/**
* Base class for benchmarks with different startup modes.
* Enables app startups from various states of baseline profile or [CompilationMode]s.
*/
abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
@get:Rule @get:Rule
val benchmarkRule = MacrobenchmarkRule() val benchmarkRule = MacrobenchmarkRule()
@ -80,12 +59,14 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
metrics = listOf(StartupTimingMetric()), metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode, compilationMode = compilationMode,
iterations = 10, iterations = 10,
startupMode = startupMode, startupMode = COLD,
setupBlock = { setupBlock = {
pressHome() pressHome()
allowNotifications()
}, },
) { ) {
startActivityAndWait() startActivityAndWait()
allowNotifications()
// Waits until the content is ready to capture Time To Full Display // Waits until the content is ready to capture Time To Full Display
forYouWaitForContent() forYouWaitForContent()
} }

@ -21,7 +21,6 @@ import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@ -46,13 +45,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
configurePrintApksTask(this) configurePrintApksTask(this)
disableUnnecessaryAndroidTests(target) disableUnnecessaryAndroidTests(target)
} }
configurations.configureEach {
resolutionStrategy {
force(libs.findLibrary("junit4").get())
// Temporary workaround for https://issuetracker.google.com/174733673
force("org.objenesis:objenesis:2.6")
}
}
dependencies { dependencies {
add("androidTestImplementation", kotlin("test")) add("androidTestImplementation", kotlin("test"))
add("testImplementation", kotlin("test")) add("testImplementation", kotlin("test"))

@ -31,10 +31,10 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> { extensions.configure<TestExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 31 defaultConfig.targetSdk = 34
configureGradleManagedDevices(this) configureGradleManagedDevices(this)
} }
} }
} }
} }

@ -21,13 +21,12 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.File
/** /**
* Configure Compose-specific options * Configure Compose-specific options
*/ */
internal fun Project.configureAndroidCompose( internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *>, commonExtension: CommonExtension<*, *, *, *, *>,
) { ) {
commonExtension.apply { commonExtension.apply {
buildFeatures { buildFeatures {
@ -55,9 +54,11 @@ internal fun Project.configureAndroidCompose(
private fun Project.buildComposeMetricsParameters(): List<String> { private fun Project.buildComposeMetricsParameters(): List<String> {
val metricParameters = mutableListOf<String>() val metricParameters = mutableListOf<String>()
val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics") val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics")
val relativePath = projectDir.relativeTo(rootDir)
val enableMetrics = (enableMetricsProvider.orNull == "true") val enableMetrics = (enableMetricsProvider.orNull == "true")
if (enableMetrics) { if (enableMetrics) {
val metricsFolder = File(project.buildDir, "compose-metrics") val metricsFolder = rootProject.buildDir.resolve("compose-metrics").resolve(relativePath)
metricParameters.add("-P") metricParameters.add("-P")
metricParameters.add( metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath
@ -67,7 +68,7 @@ private fun Project.buildComposeMetricsParameters(): List<String> {
val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports") val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports")
val enableReports = (enableReportsProvider.orNull == "true") val enableReports = (enableReportsProvider.orNull == "true")
if (enableReports) { if (enableReports) {
val reportsFolder = File(project.buildDir, "compose-reports") val reportsFolder = rootProject.buildDir.resolve("compose-reports").resolve(relativePath)
metricParameters.add("-P") metricParameters.add("-P")
metricParameters.add( metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath

@ -25,7 +25,7 @@ import org.gradle.kotlin.dsl.invoke
* Configure project for Gradle managed devices * Configure project for Gradle managed devices
*/ */
internal fun configureGradleManagedDevices( internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *>, commonExtension: CommonExtension<*, *, *, *, *>,
) { ) {
val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd") val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd")
val pixel6 = DeviceConfig("Pixel 6", 31, "aosp") val pixel6 = DeviceConfig("Pixel 6", 31, "aosp")

@ -30,7 +30,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
* Configure base Kotlin with Android options * Configure base Kotlin with Android options
*/ */
internal fun Project.configureKotlinAndroid( internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *>, commonExtension: CommonExtension<*, *, *, *, *>,
) { ) {
commonExtension.apply { commonExtension.apply {
compileSdk = 34 compileSdk = 34

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid
/** /**
* This is shared between :app and :benchmarks module to provide configurations type safety. * This is shared between :app and :benchmarks module to provide configurations type safety.
*/ */
@Suppress("unused")
enum class NiaBuildType(val applicationIdSuffix: String? = null) { enum class NiaBuildType(val applicationIdSuffix: String? = null) {
DEBUG(".debug"), DEBUG(".debug"),
RELEASE, RELEASE,

@ -20,7 +20,7 @@ enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: St
} }
fun configureFlavors( fun configureFlavors(
commonExtension: CommonExtension<*, *, *, *>, commonExtension: CommonExtension<*, *, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {} flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {}
) { ) {
commonExtension.apply { commonExtension.apply {

@ -21,7 +21,7 @@
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 )" export JAVA_HOME="$(cd $DIR/../nowinandroid-prebuilts/jdk17/linux && pwd )"
echo "JAVA_HOME=$JAVA_HOME" echo "JAVA_HOME=$JAVA_HOME"
export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )" export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -1,21 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.decoder
interface StringDecoder {
fun decodeString(encodedString: String): String
}

@ -1,24 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.decoder
import android.net.Uri
import javax.inject.Inject
class UriDecoder @Inject constructor() : StringDecoder {
override fun decodeString(encodedString: String): String = Uri.decode(encodedString)
}

@ -1,31 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.decoder.di
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.decoder.UriDecoder
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class StringDecoderModule {
@Binds
abstract fun bindStringDecoder(uriDecoder: UriDecoder): StringDecoder
}

@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.onStart
sealed interface Result<out T> { sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T> data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing> data class Error(val exception: Throwable? = null) : Result<Nothing>
object Loading : Result<Nothing> data object Loading : Result<Nothing>
} }
fun <T> Flow<T>.asResult(): Flow<Result<T>> { fun <T> Flow<T>.asResult(): Flow<Result<T>> {

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -80,17 +80,14 @@ class OfflineFirstNewsRepository @Inject constructor(
val hasOnboarded = userData.shouldHideOnboarding val hasOnboarded = userData.shouldHideOnboarding
val followedTopicIds = userData.followedTopics val followedTopicIds = userData.followedTopics
// TODO: Make this more efficient, there is no need to retrieve populated
// news resources when all that's needed are the ids
val existingNewsResourceIdsThatHaveChanged = when { val existingNewsResourceIdsThatHaveChanged = when {
hasOnboarded -> newsResourceDao.getNewsResources( hasOnboarded -> newsResourceDao.getNewsResourceIds(
useFilterTopicIds = true, useFilterTopicIds = true,
filterTopicIds = followedTopicIds, filterTopicIds = followedTopicIds,
useFilterNewsIds = true, useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(), filterNewsIds = changedIds.toSet(),
) )
.first() .first()
.map { it.entity.id }
.toSet() .toSet()
// No need to retrieve anything if notifications won't be sent // No need to retrieve anything if notifications won't be sent
else -> emptySet() else -> emptySet()

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.core.data
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
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.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
@ -164,7 +163,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/-fJ6poHQrjM", url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf(sampleTopic1), topics = listOf(sampleTopic1),
), ),
NewsResource( NewsResource(
@ -176,7 +175,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/ZARz0pjm5YM", url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf(sampleTopic1, sampleTopic2), topics = listOf(sampleTopic1, sampleTopic2),
), ),
NewsResource( NewsResource(
@ -186,7 +185,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/r5JgIyS3t3s", url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"), publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf(sampleTopic2), topics = listOf(sampleTopic2),
), ),
) )

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.core.data
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT
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.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
@ -45,7 +44,7 @@ class UserNewsResourceTest {
url = "Test URL", url = "Test URL",
headerImageUrl = "Test image URL", headerImageUrl = "Test image URL",
publishDate = Clock.System.now(), publishDate = Clock.System.now(),
type = Article, type = "Article 📚",
topics = listOf( topics = listOf(
Topic( Topic(
id = "T1", id = "T1",

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.data.model package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -56,7 +55,7 @@ class NetworkEntityKtTest {
url = "url", url = "url",
headerImageUrl = "headerImageUrl", headerImageUrl = "headerImageUrl",
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
type = Article, type = "Article 📚",
) )
val entity = networkModel.asEntity() val entity = networkModel.asEntity()
@ -66,7 +65,7 @@ class NetworkEntityKtTest {
assertEquals("url", entity.url) assertEquals("url", entity.url)
assertEquals("headerImageUrl", entity.headerImageUrl) assertEquals("headerImageUrl", entity.headerImageUrl)
assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate) assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate)
assertEquals(Article, entity.type) assertEquals("Article 📚", entity.type)
val expandedNetworkModel = val expandedNetworkModel =
NetworkNewsResourceExpanded( NetworkNewsResourceExpanded(
@ -76,7 +75,7 @@ class NetworkEntityKtTest {
url = "url", url = "url",
headerImageUrl = "headerImageUrl", headerImageUrl = "headerImageUrl",
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
type = Article, type = "Article 📚",
) )
val entityFromExpanded = expandedNetworkModel.asEntity() val entityFromExpanded = expandedNetworkModel.asEntity()
@ -87,6 +86,6 @@ class NetworkEntityKtTest {
assertEquals("url", entityFromExpanded.url) assertEquals("url", entityFromExpanded.url)
assertEquals("headerImageUrl", entityFromExpanded.headerImageUrl) assertEquals("headerImageUrl", entityFromExpanded.headerImageUrl)
assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate) assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate)
assertEquals(Article, entityFromExpanded.type) assertEquals("Article 📚", entityFromExpanded.type)
} }
} }

@ -67,6 +67,33 @@ class TestNewsResourceDao : NewsResourceDao {
result result
} }
override fun getNewsResourceIds(
useFilterTopicIds: Boolean,
filterTopicIds: Set<String>,
useFilterNewsIds: Boolean,
filterNewsIds: Set<String>,
): Flow<List<String>> =
entitiesStateFlow
.map { newsResourceEntities ->
newsResourceEntities.map { entity ->
entity.asPopulatedNewsResource(topicCrossReferences)
}
}
.map { resources ->
var result = resources
if (useFilterTopicIds) {
result = result.filter { resource ->
resource.topics.any { it.id in filterTopicIds }
}
}
if (useFilterNewsIds) {
result = result.filter { resource ->
resource.entity.id in filterNewsIds
}
}
result.map { it.entity.id }
}
override suspend fun insertOrIgnoreNewsResources( override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>, entities: List<NewsResourceEntity>,
): List<Long> { ): List<Long> {

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.database.model package com.google.samples.apps.nowinandroid.core.database.model
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.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
@ -33,7 +32,7 @@ class PopulatedNewsResourceKtTest {
content = "Hilt", content = "Hilt",
url = "url", url = "url",
headerImageUrl = "headerImageUrl", headerImageUrl = "headerImageUrl",
type = Video, type = "Video 📺",
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
), ),
topics = listOf( topics = listOf(
@ -56,7 +55,7 @@ class PopulatedNewsResourceKtTest {
content = "Hilt", content = "Hilt",
url = "url", url = "url",
headerImageUrl = "headerImageUrl", headerImageUrl = "headerImageUrl",
type = Video, type = "Video 📺",
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
topics = listOf( topics = listOf(
Topic( Topic(

@ -1,96 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.database.util
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceTypeConverterTest {
@Test
fun test_room_news_resource_type_converter_for_video() {
assertEquals(
NewsResourceType.Video,
NewsResourceTypeConverter().stringToNewsResourceType("Video 📺"),
)
}
@Test
fun test_room_news_resource_type_converter_for_article() {
assertEquals(
NewsResourceType.Article,
NewsResourceTypeConverter().stringToNewsResourceType("Article 📚"),
)
}
@Test
fun test_room_news_resource_type_converter_for_api_change() {
assertEquals(
NewsResourceType.APIChange,
NewsResourceTypeConverter().stringToNewsResourceType("API change"),
)
}
@Test
fun test_room_news_resource_type_converter_for_codelab() {
assertEquals(
NewsResourceType.Codelab,
NewsResourceTypeConverter().stringToNewsResourceType("Codelab"),
)
}
@Test
fun test_room_news_resource_type_converter_for_podcast() {
assertEquals(
NewsResourceType.Podcast,
NewsResourceTypeConverter().stringToNewsResourceType("Podcast 🎙"),
)
}
@Test
fun test_room_news_resource_type_converter_for_docs() {
assertEquals(
NewsResourceType.Docs,
NewsResourceTypeConverter().stringToNewsResourceType("Docs 📑"),
)
}
@Test
fun test_room_news_resource_type_converter_for_event() {
assertEquals(
NewsResourceType.Event,
NewsResourceTypeConverter().stringToNewsResourceType("Event 📆"),
)
}
@Test
fun test_room_news_resource_type_converter_for_dac() {
assertEquals(
NewsResourceType.DAC,
NewsResourceTypeConverter().stringToNewsResourceType("DAC"),
)
}
@Test
fun test_room_news_resource_type_converter_for_umm() {
assertEquals(
NewsResourceType.Unknown,
NewsResourceTypeConverter().stringToNewsResourceType("umm"),
)
}
}

@ -24,7 +24,6 @@ import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEnti
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
@ -305,5 +304,5 @@ private fun testNewsResource(
url = "", url = "",
headerImageUrl = "", headerImageUrl = "",
publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch), publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch),
type = NewsResourceType.DAC, type = "Article 📚",
) )

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -32,7 +32,6 @@ import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQuer
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeConverter
@Database( @Database(
entities = [ entities = [
@ -63,7 +62,6 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
) )
@TypeConverters( @TypeConverters(
InstantConverter::class, InstantConverter::class,
NewsResourceTypeConverter::class,
) )
abstract class NiaDatabase : RoomDatabase() { abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao abstract fun topicDao(): TopicDao

@ -65,6 +65,37 @@ interface NewsResourceDao {
filterNewsIds: Set<String> = emptySet(), filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>> ): Flow<List<PopulatedNewsResource>>
/**
* Fetches ids of news resources that match the query parameters
*/
@Transaction
@Query(
value = """
SELECT id FROM news_resources
WHERE
CASE WHEN :useFilterNewsIds
THEN id IN (:filterNewsIds)
ELSE 1
END
AND
CASE WHEN :useFilterTopicIds
THEN id IN
(
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds)
)
ELSE 1
END
ORDER BY publish_date DESC
""",
)
fun getNewsResourceIds(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<String>>
/** /**
* Inserts [entities] into the db if they don't exist, and ignores those that do * Inserts [entities] into the db if they don't exist, and ignores those that do
*/ */

@ -20,7 +20,6 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
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
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
@ -39,7 +38,7 @@ data class NewsResourceEntity(
val headerImageUrl: String?, val headerImageUrl: String?,
@ColumnInfo(name = "publish_date") @ColumnInfo(name = "publish_date")
val publishDate: Instant, val publishDate: Instant,
val type: NewsResourceType, val type: String,
) )
fun NewsResourceEntity.asExternalModel() = NewsResource( fun NewsResourceEntity.asExternalModel() = NewsResource(

@ -17,8 +17,6 @@
package com.google.samples.apps.nowinandroid.core.database.util package com.google.samples.apps.nowinandroid.core.database.util
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import com.google.samples.apps.nowinandroid.core.model.data.asNewsResourceType
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
class InstantConverter { class InstantConverter {
@ -30,13 +28,3 @@ class InstantConverter {
fun instantToLong(instant: Instant?): Long? = fun instantToLong(instant: Instant?): Long? =
instant?.toEpochMilliseconds() instant?.toEpochMilliseconds()
} }
class NewsResourceTypeConverter {
@TypeConverter
fun newsResourceTypeToString(value: NewsResourceType?): String? =
value?.let(NewsResourceType::serializedName)
@TypeConverter
fun stringToNewsResourceType(serializedName: String?): NewsResourceType =
serializedName.asNewsResourceType()
}

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -52,6 +52,13 @@ protobuf {
} }
} }
androidComponents.beforeVariants {
android.sourceSets.register(it.name) {
java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java"))
kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin"))
}
}
dependencies { dependencies {
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model")) implementation(project(":core:model"))

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -16,11 +16,29 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter.State.Error
import coil.compose.AsyncImagePainter.State.Loading
import coil.compose.rememberAsyncImagePainter
import com.google.samples.apps.nowinandroid.core.designsystem.R
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme
/** /**
@ -31,14 +49,37 @@ fun DynamicAsyncImage(
imageUrl: String, imageUrl: String,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeholder: Painter? = null, placeholder: Painter = painterResource(R.drawable.ic_placeholder_default),
) { ) {
val iconTint = LocalTintTheme.current.iconTint val iconTint = LocalTintTheme.current.iconTint
AsyncImage( var isLoading by remember { mutableStateOf(true) }
placeholder = placeholder, var isError by remember { mutableStateOf(false) }
val imageLoader = rememberAsyncImagePainter(
model = imageUrl, model = imageUrl,
contentDescription = contentDescription, onState = { state ->
colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null, isLoading = state is Loading
modifier = modifier, isError = state is Error
},
) )
val isLocalInspection = LocalInspectionMode.current
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
if (isLoading && !isLocalInspection) {
// Display a progress bar while loading
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(80.dp),
color = MaterialTheme.colorScheme.tertiary,
)
}
Image(
contentScale = ContentScale.Crop,
painter = if (isError.not() && !isLocalInspection) imageLoader else placeholder,
contentDescription = contentDescription,
colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null,
)
}
} }

@ -55,7 +55,7 @@ fun NiaLoadingWheel(
contentDesc: String, contentDesc: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition(label = "wheel transition")
// Specifies the float animation for slowly drawing out the lines on entering // Specifies the float animation for slowly drawing out the lines on entering
val startValue = if (LocalInspectionMode.current) 0F else 1F val startValue = if (LocalInspectionMode.current) 0F else 1F
@ -82,6 +82,7 @@ fun NiaLoadingWheel(
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing), animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing),
), ),
label = "wheel rotation animation",
) )
// Specifies the color animation for the base-to-progress line color change // Specifies the color animation for the base-to-progress line color change
@ -100,6 +101,7 @@ fun NiaLoadingWheel(
repeatMode = RepeatMode.Restart, repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index), initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index),
), ),
label = "wheel color animation",
) )
} }

@ -0,0 +1,209 @@
/*
* Copyright 2023 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive
import kotlinx.coroutines.delay
/**
* The time period for showing the scrollbar thumb after interacting with it, before it fades away
*/
private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L
/**
* A [Scrollbar] that allows for fast scrolling of content by dragging its thumb.
* Its thumb disappears when the scrolling container is dormant.
* @param modifier a [Modifier] for the [Scrollbar]
* @param state the driving state for the [Scrollbar]
* @param orientation the orientation of the scrollbar
* @param onThumbMoved the fast scroll implementation
*/
@Composable
fun ScrollableState.DraggableScrollbar(
modifier: Modifier = Modifier,
state: ScrollbarState,
orientation: Orientation,
onThumbMoved: (Float) -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
Scrollbar(
modifier = modifier,
orientation = orientation,
interactionSource = interactionSource,
state = state,
thumb = {
DraggableScrollbarThumb(
interactionSource = interactionSource,
orientation = orientation,
)
},
onThumbMoved = onThumbMoved,
)
}
/**
* A simple [Scrollbar].
* Its thumb disappears when the scrolling container is dormant.
* @param modifier a [Modifier] for the [Scrollbar]
* @param state the driving state for the [Scrollbar]
* @param orientation the orientation of the scrollbar
*/
@Composable
fun ScrollableState.DecorativeScrollbar(
modifier: Modifier = Modifier,
state: ScrollbarState,
orientation: Orientation,
) {
val interactionSource = remember { MutableInteractionSource() }
Scrollbar(
modifier = modifier,
orientation = orientation,
interactionSource = interactionSource,
state = state,
thumb = {
DecorativeScrollbarThumb(
interactionSource = interactionSource,
orientation = orientation,
)
},
)
}
/**
* A scrollbar thumb that is intended to also be a touch target for fast scrolling.
*/
@Composable
private fun ScrollableState.DraggableScrollbarThumb(
interactionSource: InteractionSource,
orientation: Orientation,
) {
Box(
modifier = Modifier
.run {
when (orientation) {
Vertical -> width(12.dp).fillMaxHeight()
Horizontal -> height(12.dp).fillMaxWidth()
}
}
.background(
color = scrollbarThumbColor(
interactionSource = interactionSource,
),
shape = RoundedCornerShape(16.dp),
),
)
}
/**
* A decorative scrollbar thumb used solely for communicating a user's position in a list.
*/
@Composable
private fun ScrollableState.DecorativeScrollbarThumb(
interactionSource: InteractionSource,
orientation: Orientation,
) {
Box(
modifier = Modifier
.run {
when (orientation) {
Vertical -> width(2.dp).fillMaxHeight()
Horizontal -> height(2.dp).fillMaxWidth()
}
}
.background(
color = scrollbarThumbColor(
interactionSource = interactionSource,
),
shape = RoundedCornerShape(16.dp),
),
)
}
/**
* The color of the scrollbar thumb as a function of its interaction state.
* @param interactionSource source of interactions in the scrolling container
*/
@Composable
private fun ScrollableState.scrollbarThumbColor(
interactionSource: InteractionSource,
): Color {
var state by remember { mutableStateOf(Dormant) }
val pressed by interactionSource.collectIsPressedAsState()
val hovered by interactionSource.collectIsHoveredAsState()
val dragged by interactionSource.collectIsDraggedAsState()
val active = (canScrollForward || canScrollForward) &&
(pressed || hovered || dragged || isScrollInProgress)
val color by animateColorAsState(
targetValue = when (state) {
Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)
Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
Dormant -> Color.Transparent
},
animationSpec = SpringSpec(
stiffness = Spring.StiffnessLow,
),
label = "Scrollbar thumb color",
)
LaunchedEffect(active) {
when (active) {
true -> state = Active
false -> if (state == Active) {
state = Inactive
delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS)
state = Dormant
}
}
}
return color
}
private enum class ThumbState {
Active, Inactive, Dormant
}

@ -0,0 +1,160 @@
/*
* Copyright 2023 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlin.math.abs
import kotlin.math.min
/**
* Calculates the [ScrollbarState] for lazy layouts.
* @param itemsAvailable the total amount of items available to scroll in the layout.
* @param visibleItems a list of items currently visible in the layout.
* @param firstVisibleItemIndex a function for interpolating the first visible index in the lazy layout
* as scrolling progresses for smooth and linear scrollbar thumb progression.
* [itemsAvailable].
* @param reverseLayout if the items in the backing lazy layout are laid out in reverse order.
* */
@Composable
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.scrollbarState(
itemsAvailable: Int,
crossinline visibleItems: LazyState.() -> List<LazyStateItem>,
crossinline firstVisibleItemIndex: LazyState.(List<LazyStateItem>) -> Float,
crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float,
crossinline reverseLayout: LazyState.() -> Boolean,
): ScrollbarState {
var state by remember { mutableStateOf(ScrollbarState.FULL) }
LaunchedEffect(
key1 = this,
key2 = itemsAvailable,
) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = visibleItems(this@scrollbarState)
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = firstVisibleItemIndex(visibleItemsInfo),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.sumOf {
itemPercentVisible(it).toDouble()
}.toFloat()
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
reverseLayout() -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
}
.filterNotNull()
.distinctUntilChanged()
.collect { state = it }
}
return state
}
/**
* Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar
* progression.
* @param visibleItems a list of items currently visible in the layout.
* @param itemSize a lookup function for the size of an item in the layout.
* @param offset a lookup function for the offset of an item relative to the start of the view port.
* @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction
* of the scroll.
* @param itemIndex a lookup function for index of an item in the layout relative to
* the total amount of items available.
*
* @return a [Float] in the range [firstItemPosition..nextItemPosition) where nextItemPosition
* is the index of the consecutive item along the major axis.
* */
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.interpolateFirstItemIndex(
visibleItems: List<LazyStateItem>,
crossinline itemSize: LazyState.(LazyStateItem) -> Int,
crossinline offset: LazyState.(LazyStateItem) -> Int,
crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?,
crossinline itemIndex: (LazyStateItem) -> Int,
): Float {
if (visibleItems.isEmpty()) return 0f
val firstItem = visibleItems.first()
val firstItemIndex = itemIndex(firstItem)
if (firstItemIndex < 0) return Float.NaN
val firstItemSize = itemSize(firstItem)
if (firstItemSize == 0) return Float.NaN
val itemOffset = offset(firstItem).toFloat()
val offsetPercentage = abs(itemOffset) / firstItemSize
val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage
val nextItemIndex = itemIndex(nextItem)
return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage)
}
/**
* Returns the percentage of an item that is currently visible in the view port.
* @param itemSize the size of the item
* @param itemStartOffset the start offset of the item relative to the view port start
* @param viewportStartOffset the start offset of the view port
* @param viewportEndOffset the end offset of the view port
*/
internal fun itemVisibilityPercentage(
itemSize: Int,
itemStartOffset: Int,
viewportStartOffset: Int,
viewportEndOffset: Int,
): Float {
if (itemSize == 0) return 0f
val itemEnd = itemStartOffset + itemSize
val startOffset = when {
itemStartOffset > viewportStartOffset -> 0
else -> abs(abs(viewportStartOffset) - abs(itemStartOffset))
}
val endOffset = when {
itemEnd < viewportEndOffset -> 0
else -> abs(abs(itemEnd) - abs(viewportEndOffset))
}
val size = itemSize.toFloat()
return (size - startOffset - endOffset) / size
}

@ -0,0 +1,402 @@
/*
* Copyright 2023 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.util.packFloats
import androidx.compose.ui.util.unpackFloat1
import androidx.compose.ui.util.unpackFloat2
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlin.math.max
import kotlin.math.min
/**
* The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll
* instead of dragging the scrollbar thumb.
*/
private const val SCROLLBAR_PRESS_DELAY_MS = 10L
/**
* The percentage displacement of the scrollbar when scrolled by long presses on the scrollbar
* track.
*/
private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f
/**
* Class definition for the core properties of a scroll bar
*/
@Immutable
@JvmInline
value class ScrollbarState internal constructor(
internal val packedValue: Long,
) {
companion object {
val FULL = ScrollbarState(
thumbSizePercent = 1f,
thumbMovedPercent = 0f,
)
}
}
/**
* Class definition for the core properties of a scroll bar track
*/
@Immutable
@JvmInline
private value class ScrollbarTrack(
val packedValue: Long,
) {
constructor(
max: Float,
min: Float,
) : this(packFloats(max, min))
}
/**
* Creates a [ScrollbarState] with the listed properties
* @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size.
* Refers to either the thumb width (for horizontal scrollbars)
* or height (for vertical scrollbars).
* @param thumbMovedPercent the distance the thumb has traveled as a percentage of total
* track size.
*/
fun ScrollbarState(
thumbSizePercent: Float,
thumbMovedPercent: Float,
) = ScrollbarState(
packFloats(
val1 = thumbSizePercent,
val2 = thumbMovedPercent,
),
)
/**
* Returns the thumb size of the scrollbar as a percentage of the total track size
*/
val ScrollbarState.thumbSizePercent
get() = unpackFloat1(packedValue)
/**
* Returns the distance the thumb has traveled as a percentage of total track size
*/
val ScrollbarState.thumbMovedPercent
get() = unpackFloat2(packedValue)
/**
* Returns the size of the scrollbar track in pixels
*/
private val ScrollbarTrack.size
get() = unpackFloat2(packedValue) - unpackFloat1(packedValue)
/**
* Returns the position of the scrollbar thumb on the track as a percentage
*/
private fun ScrollbarTrack.thumbPosition(
dimension: Float,
): Float = max(
a = min(
a = dimension / size,
b = 1f,
),
b = 0f,
)
/**
* Returns the value of [offset] along the axis specified by [this]
*/
internal fun Orientation.valueOf(offset: Offset) = when (this) {
Orientation.Horizontal -> offset.x
Orientation.Vertical -> offset.y
}
/**
* Returns the value of [intSize] along the axis specified by [this]
*/
internal fun Orientation.valueOf(intSize: IntSize) = when (this) {
Orientation.Horizontal -> intSize.width
Orientation.Vertical -> intSize.height
}
/**
* Returns the value of [intOffset] along the axis specified by [this]
*/
internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) {
Orientation.Horizontal -> intOffset.x
Orientation.Vertical -> intOffset.y
}
/**
* A Composable for drawing a scrollbar
* @param orientation the scroll direction of the scrollbar
* @param state the state describing the position of the scrollbar
* @param minThumbSize the minimum size of the scrollbar thumb
* @param interactionSource allows for observing the state of the scroll bar
* @param thumb a composable for drawing the scrollbar thumb
* @param onThumbMoved an function for reacting to scroll bar displacements caused by direct
* interactions on the scrollbar thumb by the user, for example implementing a fast scroll
*/
@Composable
fun Scrollbar(
modifier: Modifier = Modifier,
orientation: Orientation,
state: ScrollbarState,
minThumbSize: Dp = 40.dp,
interactionSource: MutableInteractionSource? = null,
thumb: @Composable () -> Unit,
onThumbMoved: ((Float) -> Unit)? = null,
) {
val localDensity = LocalDensity.current
// Using Offset.Unspecified and Float.NaN instead of null
// to prevent unnecessary boxing of primitives
var pressedOffset by remember { mutableStateOf(Offset.Unspecified) }
var draggedOffset by remember { mutableStateOf(Offset.Unspecified) }
// Used to immediately show drag feedback in the UI while the scrolling implementation
// catches up
var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) }
var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) }
val thumbTravelPercent = when {
interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent
else -> interactionThumbTravelPercent
}
val thumbSizePx = max(
a = state.thumbSizePercent * track.size,
b = with(localDensity) { minThumbSize.toPx() },
)
val thumbSizeDp by animateDpAsState(
targetValue = with(localDensity) { thumbSizePx.toDp() },
label = "scrollbar thumb size",
)
val thumbMovedPx = min(
a = track.size * thumbTravelPercent,
b = track.size - thumbSizePx,
)
// scrollbar track container
Box(
modifier = modifier
.run {
val withHover = interactionSource?.let(::hoverable) ?: this
when (orientation) {
Orientation.Vertical -> withHover.fillMaxHeight()
Orientation.Horizontal -> withHover.fillMaxWidth()
}
}
.onGloballyPositioned { coordinates ->
val scrollbarStartCoordinate = orientation.valueOf(coordinates.positionInRoot())
track = ScrollbarTrack(
max = scrollbarStartCoordinate,
min = scrollbarStartCoordinate + orientation.valueOf(coordinates.size),
)
}
// Process scrollbar presses
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
try {
// Wait for a long press before scrolling
withTimeout(viewConfiguration.longPressTimeoutMillis) {
tryAwaitRelease()
}
} catch (e: TimeoutCancellationException) {
// Start the press triggered scroll
val initialPress = PressInteraction.Press(offset)
interactionSource?.tryEmit(initialPress)
pressedOffset = offset
interactionSource?.tryEmit(
when {
tryAwaitRelease() -> PressInteraction.Release(initialPress)
else -> PressInteraction.Cancel(initialPress)
},
)
// End the press
pressedOffset = Offset.Unspecified
}
},
)
}
// Process scrollbar drags
.pointerInput(Unit) {
var dragInteraction: DragInteraction.Start? = null
val onDragStart: (Offset) -> Unit = { offset ->
val start = DragInteraction.Start()
dragInteraction = start
interactionSource?.tryEmit(start)
draggedOffset = offset
}
val onDragEnd: () -> Unit = {
dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Stop(it)) }
draggedOffset = Offset.Unspecified
}
val onDragCancel: () -> Unit = {
dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Cancel(it)) }
draggedOffset = Offset.Unspecified
}
val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit =
onDrag@{ _, delta ->
if (draggedOffset == Offset.Unspecified) return@onDrag
draggedOffset = when (orientation) {
Orientation.Vertical -> draggedOffset.copy(
y = draggedOffset.y + delta,
)
Orientation.Horizontal -> draggedOffset.copy(
x = draggedOffset.x + delta,
)
}
}
when (orientation) {
Orientation.Horizontal -> detectHorizontalDragGestures(
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
onHorizontalDrag = onDrag,
)
Orientation.Vertical -> detectVerticalDragGestures(
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
onVerticalDrag = onDrag,
)
}
},
) {
val scrollbarThumbMovedDp = max(
a = with(localDensity) { thumbMovedPx.toDp() },
b = 0.dp,
)
// scrollbar thumb container
Box(
modifier = Modifier
.align(Alignment.TopStart)
.run {
when (orientation) {
Orientation.Horizontal -> width(thumbSizeDp)
Orientation.Vertical -> height(thumbSizeDp)
}
}
.offset(
y = when (orientation) {
Orientation.Horizontal -> 0.dp
Orientation.Vertical -> scrollbarThumbMovedDp
},
x = when (orientation) {
Orientation.Horizontal -> scrollbarThumbMovedDp
Orientation.Vertical -> 0.dp
},
),
) {
thumb()
}
}
if (onThumbMoved == null) return
// State that will be read inside the effects that follow
// but will not cause re-triggering of them
val updatedState by rememberUpdatedState(state)
// Process presses
LaunchedEffect(pressedOffset) {
// Press ended, reset interactionThumbTravelPercent
if (pressedOffset == Offset.Unspecified) {
interactionThumbTravelPercent = Float.NaN
return@LaunchedEffect
}
var currentThumbMovedPercent = updatedState.thumbMovedPercent
val destinationThumbMovedPercent = track.thumbPosition(
dimension = orientation.valueOf(pressedOffset),
)
val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent
val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f
while (currentThumbMovedPercent != destinationThumbMovedPercent) {
currentThumbMovedPercent = when {
isPositive -> min(
a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent,
)
else -> max(
a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent,
)
}
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
}
}
// Process drags
LaunchedEffect(draggedOffset) {
if (draggedOffset == Offset.Unspecified) {
interactionThumbTravelPercent = Float.NaN
return@LaunchedEffect
}
val currentTravel = track.thumbPosition(
dimension = orientation.valueOf(draggedOffset),
)
onThumbMoved(currentTravel)
interactionThumbTravelPercent = currentTravel
}
}

@ -0,0 +1,104 @@
/*
* Copyright 2021 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
/**
* Calculates a [ScrollbarState] driven by the changes in a [LazyListState].
*
* @param itemsAvailable the total amount of items available to scroll in the lazy list.
* @param itemIndex a lookup function for index of an item in the list relative to [itemsAvailable].
*/
@Composable
fun LazyListState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
): ScrollbarState =
scrollbarState(
itemsAvailable = itemsAvailable,
visibleItems = { layoutInfo.visibleItemsInfo },
firstVisibleItemIndex = { visibleItems ->
interpolateFirstItemIndex(
visibleItems = visibleItems,
itemSize = { it.size },
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItems.find { it != first } },
itemIndex = itemIndex,
)
},
itemPercentVisible = itemPercentVisible@{ itemInfo ->
itemVisibilityPercentage(
itemSize = itemInfo.size,
itemStartOffset = itemInfo.offset,
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
},
reverseLayout = { layoutInfo.reverseLayout },
)
/**
* Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]
*
* @param itemsAvailable the total amount of items available to scroll in the grid.
* @param itemIndex a lookup function for index of an item in the grid relative to [itemsAvailable].
*/
@Composable
fun LazyGridState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,
): ScrollbarState =
scrollbarState(
itemsAvailable = itemsAvailable,
visibleItems = { layoutInfo.visibleItemsInfo },
firstVisibleItemIndex = { visibleItems ->
interpolateFirstItemIndex(
visibleItems = visibleItems,
itemSize = {
layoutInfo.orientation.valueOf(it.size)
},
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
when (layoutInfo.orientation) {
Orientation.Vertical -> visibleItems.find {
it != first && it.row != first.row
}
Orientation.Horizontal -> visibleItems.find {
it != first && it.column != first.column
}
}
},
itemIndex = itemIndex,
)
},
itemPercentVisible = itemPercentVisible@{ itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
},
reverseLayout = { layoutInfo.reverseLayout },
)

@ -0,0 +1,74 @@
/*
* Copyright 2023 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
/**
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState]
* @param itemsAvailable the amount of items in the list.
*/
@Composable
fun LazyListState.rememberDraggableScroller(
itemsAvailable: Int,
): (Float) -> Unit = rememberDraggableScroller(
itemsAvailable = itemsAvailable,
scroll = ::scrollToItem,
)
/**
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyGridState]
* @param itemsAvailable the amount of items in the grid.
*/
@Composable
fun LazyGridState.rememberDraggableScroller(
itemsAvailable: Int,
): (Float) -> Unit = rememberDraggableScroller(
itemsAvailable = itemsAvailable,
scroll = ::scrollToItem,
)
/**
* Generic function to react to [Scrollbar] thumb displacements in a lazy layout.
* @param itemsAvailable the total amount of items available to scroll in the layout.
* @param scroll a function to be invoked when an index has been identified to scroll to.
*/
@Composable
private inline fun rememberDraggableScroller(
itemsAvailable: Int,
crossinline scroll: suspend (index: Int) -> Unit,
): (Float) -> Unit {
var percentage by remember { mutableStateOf(Float.NaN) }
val itemCount by rememberUpdatedState(itemsAvailable)
LaunchedEffect(percentage) {
if (percentage.isNaN()) return@LaunchedEffect
val indexToFind = (itemCount * percentage).toInt()
scroll(indexToFind)
}
return remember {
{ newPercentage -> percentage = newPercentage }
}
}

@ -28,6 +28,6 @@ data class NewsResource(
val url: String, val url: String,
val headerImageUrl: String?, val headerImageUrl: String?,
val publishDate: Instant, val publishDate: Instant,
val type: NewsResourceType, val type: String,
val topics: List<Topic>, val topics: List<Topic>,
) )

@ -1,80 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.model.data
/**
* Type for [NewsResource]
*/
enum class NewsResourceType(
val serializedName: String,
val displayText: String,
// TODO: descriptions should probably be string resources
val description: String,
) {
Video(
serializedName = "Video 📺",
displayText = "Video 📺",
description = "A video published on YouTube",
),
APIChange(
serializedName = "API change",
displayText = "API change",
description = "An addition, deprecation or change to the Android platform APIs.",
),
Article(
serializedName = "Article 📚",
displayText = "Article 📚",
description = "An article, typically on Medium or the official Android blog",
),
Codelab(
serializedName = "Codelab",
displayText = "Codelab",
description = "A new or updated codelab",
),
Podcast(
serializedName = "Podcast 🎙",
displayText = "Podcast 🎙",
description = "A podcast",
),
Docs(
serializedName = "Docs 📑",
displayText = "Docs 📑",
description = "A new or updated piece of documentation",
),
Event(
serializedName = "Event 📆",
displayText = "Event 📆",
description = "Information about a developer event e.g. Android Developer Summit",
),
DAC(
serializedName = "DAC",
displayText = "DAC",
description = "Android version features - Information about features in an Android",
),
Unknown(
serializedName = "Unknown",
displayText = "Unknown",
description = "Unknown",
),
}
fun String?.asNewsResourceType() = when (this) {
null -> NewsResourceType.Unknown
else -> NewsResourceType.values()
.firstOrNull { type -> type.serializedName == this }
?: NewsResourceType.Unknown
}

@ -29,7 +29,7 @@ data class UserNewsResource internal constructor(
val url: String, val url: String,
val headerImageUrl: String?, val headerImageUrl: String?,
val publishDate: Instant, val publishDate: Instant,
val type: NewsResourceType, val type: String,
val followableTopics: List<FollowableTopic>, val followableTopics: List<FollowableTopic>,
val isSaved: Boolean, val isSaved: Boolean,
val hasBeenViewed: Boolean, val hasBeenViewed: Boolean,

@ -17,9 +17,7 @@
package com.google.samples.apps.nowinandroid.core.network.model package com.google.samples.apps.nowinandroid.core.network.model
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
import com.google.samples.apps.nowinandroid.core.network.model.util.InstantSerializer import com.google.samples.apps.nowinandroid.core.network.model.util.InstantSerializer
import com.google.samples.apps.nowinandroid.core.network.model.util.NewsResourceTypeSerializer
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -35,8 +33,7 @@ data class NetworkNewsResource(
val headerImageUrl: String, val headerImageUrl: String,
@Serializable(InstantSerializer::class) @Serializable(InstantSerializer::class)
val publishDate: Instant, val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class) val type: String,
val type: NewsResourceType,
val topics: List<String> = listOf(), val topics: List<String> = listOf(),
) )
@ -52,7 +49,6 @@ data class NetworkNewsResourceExpanded(
val headerImageUrl: String, val headerImageUrl: String,
@Serializable(InstantSerializer::class) @Serializable(InstantSerializer::class)
val publishDate: Instant, val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class) val type: String,
val type: NewsResourceType,
val topics: List<NetworkTopic> = listOf(), val topics: List<NetworkTopic> = listOf(),
) )

@ -1,39 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.network.model.util
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import com.google.samples.apps.nowinandroid.core.model.data.asNewsResourceType
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind.STRING
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object NewsResourceTypeSerializer : KSerializer<NewsResourceType> {
override fun deserialize(decoder: Decoder): NewsResourceType =
decoder.decodeString().asNewsResourceType()
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
serialName = "type",
kind = STRING,
)
override fun serialize(encoder: Encoder, value: NewsResourceType) =
encoder.encodeString(value.serializedName)
}

@ -57,10 +57,10 @@ private interface RetrofitNiaNetworkApi {
): List<NetworkChangeList> ): List<NetworkChangeList>
} }
private const val NiaBaseUrl = BuildConfig.BACKEND_URL private const val NIA_BASE_URL = BuildConfig.BACKEND_URL
/** /**
* Wrapper for data provided from the [NiaBaseUrl] * Wrapper for data provided from the [NIA_BASE_URL]
*/ */
@Serializable @Serializable
private data class NetworkResponse<T>( private data class NetworkResponse<T>(
@ -77,7 +77,7 @@ class RetrofitNiaNetwork @Inject constructor(
) : NiaNetworkDataSource { ) : NiaNetworkDataSource {
private val networkApi = Retrofit.Builder() private val networkApi = Retrofit.Builder()
.baseUrl(NiaBaseUrl) .baseUrl(NIA_BASE_URL)
.callFactory(okhttpCallFactory) .callFactory(okhttpCallFactory)
.addConverterFactory( .addConverterFactory(
networkJson.asConverterFactory("application/json".toMediaType()), networkJson.asConverterFactory("application/json".toMediaType()),

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.network.fake package com.google.samples.apps.nowinandroid.core.network.fake
import JvmUnitTestFakeAssetManager import JvmUnitTestFakeAssetManager
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -81,7 +80,7 @@ class FakeNiaNetworkDataSourceTest {
second = 0, second = 0,
nanosecond = 0, nanosecond = 0,
).toInstant(TimeZone.UTC), ).toInstant(TimeZone.UTC),
type = Codelab, type = "Codelab",
topics = listOf("2", "3", "10"), topics = listOf("2", "3", "10"),
), ),
/* ktlint-enable max-line-length */ /* ktlint-enable max-line-length */

@ -1,106 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.network.model.util
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlinx.serialization.json.Json
import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceTypeSerializerTest {
@Test
fun test_news_resource_serializer_video() {
assertEquals(
NewsResourceType.Video,
Json.decodeFromString(NewsResourceTypeSerializer, """"Video 📺""""),
)
}
@Test
fun test_news_resource_serializer_article() {
assertEquals(
NewsResourceType.Article,
Json.decodeFromString(NewsResourceTypeSerializer, """"Article 📚""""),
)
}
@Test
fun test_news_resource_serializer_api_change() {
assertEquals(
NewsResourceType.APIChange,
Json.decodeFromString(NewsResourceTypeSerializer, """"API change""""),
)
}
@Test
fun test_news_resource_serializer_codelab() {
assertEquals(
NewsResourceType.Codelab,
Json.decodeFromString(NewsResourceTypeSerializer, """"Codelab""""),
)
}
@Test
fun test_news_resource_serializer_podcast() {
assertEquals(
NewsResourceType.Podcast,
Json.decodeFromString(NewsResourceTypeSerializer, """"Podcast 🎙""""),
)
}
@Test
fun test_news_resource_serializer_docs() {
assertEquals(
NewsResourceType.Docs,
Json.decodeFromString(NewsResourceTypeSerializer, """"Docs 📑""""),
)
}
@Test
fun test_news_resource_serializer_event() {
assertEquals(
NewsResourceType.Event,
Json.decodeFromString(NewsResourceTypeSerializer, """"Event 📆""""),
)
}
@Test
fun test_news_resource_serializer_dac() {
assertEquals(
NewsResourceType.DAC,
Json.decodeFromString(NewsResourceTypeSerializer, """"DAC""""),
)
}
@Test
fun test_news_resource_serializer_unknown() {
assertEquals(
NewsResourceType.Unknown,
Json.decodeFromString(NewsResourceTypeSerializer, """"umm""""),
)
}
@Test
fun test_serialize_and_deserialize() {
val json = Json.encodeToString(NewsResourceTypeSerializer, NewsResourceType.Video)
assertEquals(
NewsResourceType.Video,
Json.decodeFromString(NewsResourceTypeSerializer, json),
)
}
}

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -37,7 +37,7 @@ val followableTopicTestData: List<FollowableTopic> = listOf(
id = "3", id = "3",
name = "UI", name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = "", url = "",
), ),

@ -17,9 +17,6 @@
package com.google.samples.apps.nowinandroid.core.testing.data package com.google.samples.apps.nowinandroid.core.testing.data
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.Codelab
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/* ktlint-disable max-line-length */ /* ktlint-disable max-line-length */
@ -31,7 +28,7 @@ val newsResourcesTestData: List<NewsResource> = listOf(
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Codelab, type = "Codelab",
topics = listOf(topicsTestData[1]), topics = listOf(topicsTestData[1]),
), ),
NewsResource( NewsResource(
@ -44,7 +41,7 @@ val newsResourcesTestData: List<NewsResource> = listOf(
url = "https://youtu.be/-fJ6poHQrjM", url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf(topicsTestData[0], topicsTestData[1]), topics = listOf(topicsTestData[0], topicsTestData[1]),
), ),
NewsResource( NewsResource(
@ -57,7 +54,7 @@ val newsResourcesTestData: List<NewsResource> = listOf(
url = "https://youtu.be/ZARz0pjm5YM", url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
NewsResource( NewsResource(
@ -68,7 +65,7 @@ val newsResourcesTestData: List<NewsResource> = listOf(
url = "https://developer.android.com/jetpack/androidx/versions/all-channel", url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "", headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"), publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown, type = "",
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
) )

@ -32,7 +32,7 @@ val topicsTestData: List<Topic> = listOf(
id = "3", id = "3",
name = "UI", name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = "", url = "",
), ),

@ -18,9 +18,6 @@ package com.google.samples.apps.nowinandroid.core.testing.data
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
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.Codelab
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -56,7 +53,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
second = 0, second = 0,
nanosecond = 0, nanosecond = 0,
).toInstant(TimeZone.UTC), ).toInstant(TimeZone.UTC),
type = Codelab, type = "Codelab",
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
userData = userData, userData = userData,
@ -72,7 +69,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://youtu.be/-fJ6poHQrjM", url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = topicsTestData.take(2), topics = topicsTestData.take(2),
), ),
userData = userData, userData = userData,
@ -88,7 +85,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://youtu.be/ZARz0pjm5YM", url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
userData = userData, userData = userData,
@ -102,7 +99,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://developer.android.com/jetpack/androidx/versions/all-channel", url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "", headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"), publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown, type = "",
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
userData = userData, userData = userData,

@ -1,24 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.testing.decoder
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import javax.inject.Inject
class FakeStringDecoder @Inject constructor() : StringDecoder {
override fun decodeString(encodedString: String): String = encodedString
}

@ -1,35 +0,0 @@
/*
* 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.core.testing.di
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.decoder.di.StringDecoderModule
import com.google.samples.apps.nowinandroid.core.testing.decoder.FakeStringDecoder
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [StringDecoderModule::class],
)
abstract class TestStringDecoderModule {
@Binds
abstract fun bindsStringDecoder(fakeStringDecoder: FakeStringDecoder): StringDecoder
}

@ -34,7 +34,7 @@ class NewsResourceCardTest {
@Test @Test
fun testMetaDataDisplay_withCodelabResource() { fun testMetaDataDisplay_withCodelabResource() {
val newsWithKnownResourceType = userNewsResourcesTestData[0] val newsWithKnownResourceType = userNewsResourcesTestData[0]
var dateFormatted = "" lateinit var dateFormatted: String
composeTestRule.setContent { composeTestRule.setContent {
NewsResourceCardExpanded( NewsResourceCardExpanded(
@ -54,20 +54,20 @@ class NewsResourceCardTest {
composeTestRule.activity.getString( composeTestRule.activity.getString(
R.string.card_meta_data_text, R.string.card_meta_data_text,
dateFormatted, dateFormatted,
newsWithKnownResourceType.type.displayText, newsWithKnownResourceType.type,
), ),
) )
.assertExists() .assertExists()
} }
@Test @Test
fun testMetaDataDisplay_withUnknownResource() { fun testMetaDataDisplay_withEmptyResourceType() {
val newsWithUnknownResourceType = userNewsResourcesTestData[3] val newsWithEmptyResourceType = userNewsResourcesTestData[3]
var dateFormatted = "" lateinit var dateFormatted: String
composeTestRule.setContent { composeTestRule.setContent {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = newsWithUnknownResourceType, userNewsResource = newsWithEmptyResourceType,
isBookmarked = false, isBookmarked = false,
hasBeenViewed = false, hasBeenViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
@ -75,7 +75,7 @@ class NewsResourceCardTest {
onTopicClick = {}, onTopicClick = {},
) )
dateFormatted = dateFormatted(publishDate = newsWithUnknownResourceType.publishDate) dateFormatted = dateFormatted(publishDate = newsWithEmptyResourceType.publishDate)
} }
composeTestRule composeTestRule

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -45,7 +45,7 @@ class FollowableTopicPreviewParameterProvider : PreviewParameterProvider<List<Fo
id = "3", id = "3",
name = "UI", name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = "", url = "",
), ),

@ -112,7 +112,7 @@ sealed interface NewsFeedUiState {
/** /**
* The feed is still loading. * The feed is still loading.
*/ */
object Loading : NewsFeedUiState data object Loading : NewsFeedUiState
/** /**
* The feed is loaded with the given list of news resources. * The feed is loaded with the given list of news resources.

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.ui package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -31,6 +32,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -57,14 +59,15 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import com.google.samples.apps.nowinandroid.core.designsystem.R.drawable
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag
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.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant import kotlinx.datetime.toJavaInstant
@ -72,7 +75,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR
/** /**
* [NewsResource] card used on the following screens: For You, Saved * [NewsResource] card used on the following screens: For You, Saved
@ -147,21 +149,46 @@ fun NewsResourceCardExpanded(
fun NewsResourceHeaderImage( fun NewsResourceHeaderImage(
headerImageUrl: String?, headerImageUrl: String?,
) { ) {
AsyncImage( var isLoading by remember { mutableStateOf(true) }
placeholder = if (LocalInspectionMode.current) { var isError by remember { mutableStateOf(false) }
painterResource(DesignsystemR.drawable.ic_placeholder_default) val imageLoader = rememberAsyncImagePainter(
} else { model = headerImageUrl,
// TODO b/228077205, show specific loading image visual onState = { state ->
null isLoading = state is AsyncImagePainter.State.Loading
isError = state is AsyncImagePainter.State.Error
}, },
)
val isLocalInspection = LocalInspectionMode.current
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp), .height(180.dp),
contentScale = ContentScale.Crop, contentAlignment = Alignment.Center,
model = headerImageUrl, ) {
// TODO b/226661685: Investigate using alt text of image to populate content description if (isLoading) {
contentDescription = null, // decorative image // Display a progress bar while loading
) CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(80.dp),
color = MaterialTheme.colorScheme.tertiary,
)
}
Image(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentScale = ContentScale.Crop,
painter = if (isError.not() && !isLocalInspection) {
imageLoader
} else {
painterResource(drawable.ic_placeholder_default)
},
// TODO b/226661685: Investigate using alt text of image to populate content description
contentDescription = null, // decorative image,
)
}
} }
@Composable @Composable
@ -241,12 +268,12 @@ fun dateFormatted(publishDate: Instant): String {
@Composable @Composable
fun NewsResourceMetaData( fun NewsResourceMetaData(
publishDate: Instant, publishDate: Instant,
resourceType: NewsResourceType, resourceType: String,
) { ) {
val formattedDate = dateFormatted(publishDate) val formattedDate = dateFormatted(publishDate)
Text( Text(
if (resourceType != NewsResourceType.Unknown) { if (resourceType.isNotBlank()) {
stringResource(R.string.card_meta_data_text, formattedDate, resourceType.displayText) stringResource(R.string.card_meta_data_text, formattedDate, resourceType)
} else { } else {
formattedDate formattedDate
}, },

@ -19,8 +19,6 @@ package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
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
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
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.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
@ -66,7 +64,7 @@ object PreviewParameterData {
id = "3", id = "3",
name = "UI", name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = "", url = "",
), ),
@ -97,7 +95,7 @@ object PreviewParameterData {
second = 0, second = 0,
nanosecond = 0, nanosecond = 0,
).toInstant(TimeZone.UTC), ).toInstant(TimeZone.UTC),
type = NewsResourceType.Codelab, type = "Codelab",
topics = listOf(topics[2]), topics = listOf(topics[2]),
), ),
userData = userData, userData = userData,
@ -113,7 +111,7 @@ object PreviewParameterData {
url = "https://youtu.be/-fJ6poHQrjM", url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = topics.take(2), topics = topics.take(2),
), ),
userData = userData, userData = userData,
@ -129,7 +127,7 @@ object PreviewParameterData {
url = "https://youtu.be/ZARz0pjm5YM", url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf(topics[2]), topics = listOf(topics[2]),
), ),
userData = userData, userData = userData,

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -18,17 +18,22 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
@ -57,6 +62,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme
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.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -169,25 +177,49 @@ private fun BookmarksGrid(
) { ) {
val scrollableState = rememberLazyGridState() val scrollableState = rememberLazyGridState()
TrackScrollJank(scrollableState = scrollableState, stateName = "bookmarks:grid") TrackScrollJank(scrollableState = scrollableState, stateName = "bookmarks:grid")
LazyVerticalGrid( Box(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
state = scrollableState,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize(),
.testTag("bookmarks:feed"),
) { ) {
newsFeed( LazyVerticalGrid(
feedState = feedState, columns = Adaptive(300.dp),
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, contentPadding = PaddingValues(16.dp),
onNewsResourceViewed = onNewsResourceViewed, horizontalArrangement = Arrangement.spacedBy(16.dp),
onTopicClick = onTopicClick, verticalArrangement = Arrangement.spacedBy(24.dp),
) state = scrollableState,
item(span = { GridItemSpan(maxLineSpan) }) { modifier = Modifier
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) .fillMaxSize()
.testTag("bookmarks:feed"),
) {
newsFeed(
feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
)
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
val itemsAvailable = when (feedState) {
Loading -> 1
is Success -> feedState.feed.size
}
val scrollbarState = scrollableState.scrollbarState(
itemsAvailable = itemsAvailable,
)
scrollableState.DraggableScrollbar(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 2.dp)
.align(Alignment.CenterEnd),
state = scrollbarState,
orientation = Orientation.Vertical,
onThumbMoved = scrollableState.rememberDraggableScroller(
itemsAvailable = itemsAvailable,
),
)
} }
} }

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -25,6 +25,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@ -33,6 +34,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -40,9 +42,11 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
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.GridCells.Adaptive
@ -54,7 +58,6 @@ 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.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -87,6 +90,10 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicA
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DecorativeScrollbar
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
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.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -144,75 +151,96 @@ internal fun ForYouScreen(
// This code should be called when the UI is ready for use and relates to Time To Full Display. // This code should be called when the UI is ready for use and relates to Time To Full Display.
ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading } ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading }
val itemsAvailable = feedItemsSize(feedState, onboardingUiState)
val state = rememberLazyGridState() val state = rememberLazyGridState()
val scrollbarState = state.scrollbarState(
itemsAvailable = itemsAvailable,
)
TrackScrollJank(scrollableState = state, stateName = "forYou:feed") TrackScrollJank(scrollableState = state, stateName = "forYou:feed")
LazyVerticalGrid( Box(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize(),
.testTag("forYou:feed"),
state = state,
) { ) {
onboarding( LazyVerticalGrid(
onboardingUiState = onboardingUiState, columns = Adaptive(300.dp),
onTopicCheckedChanged = onTopicCheckedChanged, contentPadding = PaddingValues(16.dp),
saveFollowedTopics = saveFollowedTopics, horizontalArrangement = Arrangement.spacedBy(16.dp),
// Custom LayoutModifier to remove the enforced parent 16.dp contentPadding verticalArrangement = Arrangement.spacedBy(24.dp),
// from the LazyVerticalGrid and enable edge-to-edge scrolling for this section modifier = Modifier
interestsItemModifier = Modifier.layout { measurable, constraints -> .testTag("forYou:feed"),
val placeable = measurable.measure( state = state,
constraints.copy( ) {
maxWidth = constraints.maxWidth + 32.dp.roundToPx(), onboarding(
), onboardingUiState = onboardingUiState,
) onTopicCheckedChanged = onTopicCheckedChanged,
layout(placeable.width, placeable.height) { saveFollowedTopics = saveFollowedTopics,
placeable.place(0, 0) // Custom LayoutModifier to remove the enforced parent 16.dp contentPadding
} // from the LazyVerticalGrid and enable edge-to-edge scrolling for this section
}, interestsItemModifier = Modifier.layout { measurable, constraints ->
) val placeable = measurable.measure(
constraints.copy(
maxWidth = constraints.maxWidth + 32.dp.roundToPx(),
),
)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
},
)
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed, onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") { item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") {
Column { Column {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar. // Add space for the content to clear the "offline" snackbar.
// TODO: Check that the Scaffold handles this correctly in NiaApp // TODO: Check that the Scaffold handles this correctly in NiaApp
// if (isOffline) Spacer(modifier = Modifier.height(48.dp)) // if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
} }
} AnimatedVisibility(
AnimatedVisibility( visible = isSyncing || isFeedLoading || isOnboardingLoading,
visible = isSyncing || isFeedLoading || isOnboardingLoading, enter = slideInVertically(
enter = slideInVertically( initialOffsetY = { fullHeight -> -fullHeight },
initialOffsetY = { fullHeight -> -fullHeight }, ) + fadeIn(),
) + fadeIn(), exit = slideOutVertically(
exit = slideOutVertically( targetOffsetY = { fullHeight -> -fullHeight },
targetOffsetY = { fullHeight -> -fullHeight }, ) + fadeOut(),
) + fadeOut(),
) {
val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) { ) {
NiaOverlayLoadingWheel( val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
modifier = Modifier modifier = Modifier
.align(Alignment.Center), .fillMaxWidth()
contentDesc = loadingContentDescription, .padding(top = 8.dp),
) ) {
NiaOverlayLoadingWheel(
modifier = Modifier
.align(Alignment.Center),
contentDesc = loadingContentDescription,
)
}
} }
state.DraggableScrollbar(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 2.dp)
.align(Alignment.CenterEnd),
state = scrollbarState,
orientation = Orientation.Vertical,
onThumbMoved = state.rememberDraggableScroller(
itemsAvailable = itemsAvailable,
),
)
} }
TrackScreenViewEvent(screenName = "ForYou") TrackScreenViewEvent(screenName = "ForYou")
NotificationPermissionEffect() NotificationPermissionEffect()
@ -298,42 +326,54 @@ private fun TopicSelection(
TrackScrollJank(scrollableState = lazyGridState, stateName = topicSelectionTestTag) TrackScrollJank(scrollableState = lazyGridState, stateName = topicSelectionTestTag)
LazyHorizontalGrid( Box(
state = lazyGridState,
rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.dp),
modifier = modifier modifier = modifier
// LazyHorizontalGrid has to be constrained in height. .fillMaxWidth(),
// However, we can't set a fixed height because the horizontal grid contains
// vertical text that can be rescaled.
// When the fontScale is at most 1, we know that the horizontal grid will be at most
// 240dp tall, so this is an upper bound for when the font scale is at most 1.
// When the fontScale is greater than 1, the height required by the text inside the
// horizontal grid will increase by at most the same factor, so 240sp is a valid
// upper bound for how much space we need in that case.
// The maximum of these two bounds is therefore a valid upper bound in all cases.
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth()
.testTag(topicSelectionTestTag),
) { ) {
items( LazyHorizontalGrid(
items = onboardingUiState.topics, state = lazyGridState,
key = { it.topic.id }, rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.dp),
modifier = Modifier
// LazyHorizontalGrid has to be constrained in height.
// However, we can't set a fixed height because the horizontal grid contains
// vertical text that can be rescaled.
// When the fontScale is at most 1, we know that the horizontal grid will be at most
// 240dp tall, so this is an upper bound for when the font scale is at most 1.
// When the fontScale is greater than 1, the height required by the text inside the
// horizontal grid will increase by at most the same factor, so 240sp is a valid
// upper bound for how much space we need in that case.
// The maximum of these two bounds is therefore a valid upper bound in all cases.
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth()
.testTag(topicSelectionTestTag),
) { ) {
SingleTopicButton( items(
name = it.topic.name, items = onboardingUiState.topics,
topicId = it.topic.id, key = { it.topic.id },
imageUrl = it.topic.imageUrl, ) {
isSelected = it.isFollowed, SingleTopicButton(
onClick = onTopicCheckedChanged, name = it.topic.name,
) topicId = it.topic.id,
imageUrl = it.topic.imageUrl,
isSelected = it.isFollowed,
onClick = onTopicCheckedChanged,
)
}
} }
lazyGridState.DecorativeScrollbar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.align(Alignment.BottomStart),
state = lazyGridState.scrollbarState(itemsAvailable = onboardingUiState.topics.size),
orientation = Orientation.Horizontal,
)
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SingleTopicButton( private fun SingleTopicButton(
name: String, name: String,
@ -394,7 +434,6 @@ fun TopicIcon(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
DynamicAsyncImage( DynamicAsyncImage(
// TODO b/228077205, show loading image visual instead of static placeholder
placeholder = painterResource(R.drawable.ic_icon_placeholder), placeholder = painterResource(R.drawable.ic_icon_placeholder),
imageUrl = imageUrl, imageUrl = imageUrl,
contentDescription = null, // decorative contentDescription = null, // decorative
@ -442,6 +481,25 @@ private fun DeepLinkEffect(
} }
} }
private fun feedItemsSize(
feedState: NewsFeedUiState,
onboardingUiState: OnboardingUiState,
): Int {
val feedSize = when (feedState) {
NewsFeedUiState.Loading -> 0
is NewsFeedUiState.Success -> feedState.feed.size
}
val onboardingSize = when (onboardingUiState) {
OnboardingUiState.Loading,
OnboardingUiState.LoadFailed,
OnboardingUiState.NotShown,
-> 0
is OnboardingUiState.Shown -> 1
}
return feedSize + onboardingSize
}
@DevicePreviews @DevicePreviews
@Composable @Composable
fun ForYouScreenPopulatedFeed( fun ForYouScreenPopulatedFeed(

@ -25,17 +25,17 @@ sealed interface OnboardingUiState {
/** /**
* The onboarding state is loading. * The onboarding state is loading.
*/ */
object Loading : OnboardingUiState data object Loading : OnboardingUiState
/** /**
* The onboarding state was unable to load. * The onboarding state was unable to load.
*/ */
object LoadFailed : OnboardingUiState data object LoadFailed : OnboardingUiState
/** /**
* There is no onboarding state. * There is no onboarding state.
*/ */
object NotShown : OnboardingUiState data object NotShown : OnboardingUiState
/** /**
* There is a onboarding state, with the given lists of topics. * There is a onboarding state, with the given lists of topics.

@ -23,7 +23,6 @@ import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNe
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
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.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
@ -545,7 +544,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/-fJ6poHQrjM", url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf( topics = listOf(
Topic( Topic(
id = "0", id = "0",
@ -566,7 +565,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/ZARz0pjm5YM", url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf( topics = listOf(
Topic( Topic(
id = "1", id = "1",
@ -585,7 +584,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/r5JgIyS3t3s", url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"), publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf( topics = listOf(
Topic( Topic(
id = "1", id = "1",

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -18,22 +18,20 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
@ -51,63 +49,46 @@ fun InterestsItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier, iconModifier: Modifier = Modifier,
description: String = "", description: String = "",
itemSeparation: Dp = 16.dp,
) { ) {
Row( ListItem(
verticalAlignment = Alignment.CenterVertically, leadingContent = {
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.clickable { onClick() }
.padding(vertical = itemSeparation),
) {
InterestsIcon(topicImageUrl, iconModifier.size(64.dp)) InterestsIcon(topicImageUrl, iconModifier.size(64.dp))
Spacer(modifier = Modifier.width(24.dp)) },
InterestContent(name, description) headlineContent = {
} Text(text = name)
NiaIconToggleButton( },
checked = following, supportingContent = {
onCheckedChange = onFollowButtonClick, Text(text = description)
icon = { },
Icon( trailingContent = {
imageVector = NiaIcons.Add, NiaIconToggleButton(
contentDescription = stringResource( checked = following,
id = string.card_follow_button_content_desc, onCheckedChange = onFollowButtonClick,
), icon = {
) Icon(
}, imageVector = NiaIcons.Add,
checkedIcon = { contentDescription = stringResource(
Icon( id = string.card_follow_button_content_desc,
imageVector = NiaIcons.Check, ),
contentDescription = stringResource( )
id = string.card_unfollow_button_content_desc, },
), checkedIcon = {
) Icon(
}, imageVector = NiaIcons.Check,
) contentDescription = stringResource(
} id = string.card_unfollow_button_content_desc,
} ),
)
@Composable },
private fun InterestContent(name: String, description: String, modifier: Modifier = Modifier) {
Column(modifier) {
Text(
text = name,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(
vertical = if (description.isEmpty()) 0.dp else 4.dp,
),
)
if (description.isNotEmpty()) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
) )
} },
} colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
modifier = modifier
.semantics(mergeDescendants = true) { /* no-op */ }
.clickable(enabled = true, onClick = onClick),
)
} }
@Composable @Composable

@ -53,11 +53,11 @@ class InterestsViewModel @Inject constructor(
} }
sealed interface InterestsUiState { sealed interface InterestsUiState {
object Loading : InterestsUiState data object Loading : InterestsUiState
data class Interests( data class Interests(
val topics: List<FollowableTopic>, val topics: List<FollowableTopic>,
) : InterestsUiState ) : InterestsUiState
object Empty : InterestsUiState data object Empty : InterestsUiState
} }

@ -16,17 +16,28 @@
package com.google.samples.apps.nowinandroid.feature.interests package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@Composable @Composable
@ -37,30 +48,52 @@ fun TopicsTabContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true, withBottomSpacer: Boolean = true,
) { ) {
LazyColumn( Box(
modifier = modifier modifier = modifier
.padding(horizontal = 24.dp) .fillMaxWidth(),
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
) { ) {
topics.forEach { followableTopic -> val scrollableState = rememberLazyListState()
val topicId = followableTopic.topic.id LazyColumn(
item(key = topicId) { modifier = Modifier
InterestsItem( .padding(horizontal = 24.dp)
name = followableTopic.topic.name, .testTag("interests:topics"),
following = followableTopic.isFollowed, contentPadding = PaddingValues(vertical = 16.dp),
description = followableTopic.topic.shortDescription, state = scrollableState,
topicImageUrl = followableTopic.topic.imageUrl, ) {
onClick = { onTopicClick(topicId) }, topics.forEach { followableTopic ->
onFollowButtonClick = { onFollowButtonClick(topicId, it) }, val topicId = followableTopic.topic.id
) item(key = topicId) {
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
} }
}
if (withBottomSpacer) { if (withBottomSpacer) {
item { item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
} }
val scrollbarState = scrollableState.scrollbarState(
itemsAvailable = topics.size,
)
scrollableState.DraggableScrollbar(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 2.dp)
.align(Alignment.CenterEnd),
state = scrollbarState,
orientation = Orientation.Vertical,
onThumbMoved = scrollableState.rememberDraggableScroller(
itemsAvailable = topics.size,
),
)
} }
} }

@ -23,11 +23,11 @@ import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
private const val interestsGraphRoutePattern = "interests_graph" private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph"
const val interestsRoute = "interests_route" const val interestsRoute = "interests_route"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) { fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
this.navigate(interestsGraphRoutePattern, navOptions) this.navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
} }
fun NavGraphBuilder.interestsGraph( fun NavGraphBuilder.interestsGraph(
@ -35,7 +35,7 @@ fun NavGraphBuilder.interestsGraph(
nestedGraphs: NavGraphBuilder.() -> Unit, nestedGraphs: NavGraphBuilder.() -> Unit,
) { ) {
navigation( navigation(
route = interestsGraphRoutePattern, route = INTERESTS_GRAPH_ROUTE_PATTERN,
startDestination = interestsRoute, startDestination = interestsRoute,
) { ) {
composable(route = interestsRoute) { composable(route = interestsRoute) {

@ -20,11 +20,14 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToIndex
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
@ -139,15 +142,18 @@ class SearchScreenTest {
composeTestRule composeTestRule
.onNodeWithText(topicsString) .onNodeWithText(topicsString)
.assertIsDisplayed() .assertIsDisplayed()
composeTestRule
.onNodeWithText(followableTopicTestData[0].topic.name) val scrollableNode = composeTestRule
.assertIsDisplayed() .onAllNodes(hasScrollToNodeAction())
composeTestRule .onFirst()
.onNodeWithText(followableTopicTestData[1].topic.name)
.assertIsDisplayed() followableTopicTestData.forEachIndexed { index, followableTopic ->
composeTestRule scrollableNode.performScrollToIndex(index)
.onNodeWithText(followableTopicTestData[2].topic.name)
.assertIsDisplayed() composeTestRule
.onNodeWithText(followableTopic.topic.name)
.assertIsDisplayed()
}
composeTestRule composeTestRule
.onAllNodesWithContentDescription(followButtonContentDesc) .onAllNodesWithContentDescription(followButtonContentDesc)

@ -14,4 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" /> <manifest />

@ -17,17 +17,22 @@
package com.google.samples.apps.nowinandroid.feature.search package com.google.samples.apps.nowinandroid.feature.search
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
@ -75,12 +80,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
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.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
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.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import com.google.samples.apps.nowinandroid.core.ui.R.string import com.google.samples.apps.nowinandroid.core.ui.R.string
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@ -289,81 +297,102 @@ private fun SearchResultBody(
searchQuery: String = "", searchQuery: String = "",
) { ) {
val state = rememberLazyGridState() val state = rememberLazyGridState()
LazyVerticalGrid( Box(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize(),
.testTag("search:newsResources"),
state = state,
) { ) {
if (topics.isNotEmpty()) { LazyVerticalGrid(
item( columns = Adaptive(300.dp),
span = { contentPadding = PaddingValues(16.dp),
GridItemSpan(maxLineSpan) horizontalArrangement = Arrangement.spacedBy(16.dp),
}, verticalArrangement = Arrangement.spacedBy(24.dp),
) { modifier = Modifier
Text( .fillMaxSize()
text = buildAnnotatedString { .testTag("search:newsResources"),
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { state = state,
append(stringResource(id = searchR.string.topics)) ) {
} if (topics.isNotEmpty()) {
item(
span = {
GridItemSpan(maxLineSpan)
}, },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) {
) Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.topics))
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
topics.forEach { followableTopic ->
val topicId = followableTopic.topic.id
item(
key = "topic-$topicId", // Append a prefix to distinguish a key for news resources
span = {
GridItemSpan(maxLineSpan)
},
) {
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = {
// Pass the current search query to ViewModel to save it as recent searches
onSearchTriggered(searchQuery)
onTopicClick(topicId)
},
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
}
} }
topics.forEach { followableTopic ->
val topicId = followableTopic.topic.id if (newsResources.isNotEmpty()) {
item( item(
key = "topic-$topicId", // Append a prefix to distinguish a key for news resources
span = { span = {
GridItemSpan(maxLineSpan) GridItemSpan(maxLineSpan)
}, },
) { ) {
InterestsItem( Text(
name = followableTopic.topic.name, text = buildAnnotatedString {
following = followableTopic.isFollowed, withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
description = followableTopic.topic.shortDescription, append(stringResource(id = searchR.string.updates))
topicImageUrl = followableTopic.topic.imageUrl, }
onClick = {
// Pass the current search query to ViewModel to save it as recent searches
onSearchTriggered(searchQuery)
onTopicClick(topicId)
}, },
onFollowButtonClick = { onFollowButtonClick(topicId, it) }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
} }
}
}
if (newsResources.isNotEmpty()) { newsFeed(
item( feedState = Success(feed = newsResources),
span = { onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
GridItemSpan(maxLineSpan) onNewsResourceViewed = onNewsResourceViewed,
}, onTopicClick = onTopicClick,
) { onExpandedCardClick = {
Text( onSearchTriggered(searchQuery)
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.updates))
}
}, },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
} }
newsFeed(
feedState = NewsFeedUiState.Success(feed = newsResources),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
onExpandedCardClick = {
onSearchTriggered(searchQuery)
},
)
} }
val itemsAvailable = topics.size + newsResources.size
val scrollbarState = state.scrollbarState(
itemsAvailable = itemsAvailable,
)
state.DraggableScrollbar(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 2.dp)
.align(Alignment.CenterEnd),
state = scrollbarState,
orientation = Orientation.Vertical,
onThumbMoved = state.rememberDraggableScroller(
itemsAvailable = itemsAvailable,
),
)
} }
} }

@ -18,7 +18,7 @@
<string name="search">Search</string> <string name="search">Search</string>
<string name="clear_search_text_content_desc">Clear search text</string> <string name="clear_search_text_content_desc">Clear search text</string>
<string name="search_result_not_found">Sorry, there is no content found for your search \"%1$s\"</string> <string name="search_result_not_found">Sorry, there is no content found for your search \"%1$s\"</string>
<string name="search_not_ready">Sorry, we are still processing the search index. Please come back later</string> <string name="search_not_ready">Sorry, we are still processing the search index. Please come back later.</string>
<string name="try_another_search">Try another search or explorer </string> <string name="try_another_search">Try another search or explorer </string>
<string name="interests">Interests</string> <string name="interests">Interests</string>
<string name="to_browse_topics"> to browse topics</string> <string name="to_browse_topics"> to browse topics</string>
@ -26,4 +26,4 @@
<string name="updates">Updates</string> <string name="updates">Updates</string>
<string name="recent_searches">Recent searches</string> <string name="recent_searches">Recent searches</string>
<string name="clear_recent_searches_content_desc">Clear searches</string> <string name="clear_recent_searches_content_desc">Clear searches</string>
</resources> </resources>

@ -175,7 +175,7 @@ private fun ColumnScope.SettingsPanel(
} }
AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) { AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) {
Column { Column {
SettingsDialogSectionTitle(text = stringResource(R.string.dynamic_color_preference)) SettingsDialogSectionTitle(text = stringResource(string.dynamic_color_preference))
Column(Modifier.selectableGroup()) { Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow( SettingsDialogThemeChooserRow(
text = stringResource(string.dynamic_color_yes), text = stringResource(string.dynamic_color_yes),
@ -190,7 +190,7 @@ private fun ColumnScope.SettingsPanel(
} }
} }
} }
SettingsDialogSectionTitle(text = stringResource(R.string.dark_mode_preference)) SettingsDialogSectionTitle(text = stringResource(string.dark_mode_preference))
Column(Modifier.selectableGroup()) { Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow( SettingsDialogThemeChooserRow(
text = stringResource(string.dark_mode_config_system_default), text = stringResource(string.dark_mode_config_system_default),

@ -14,6 +14,4 @@
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.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest />
</manifest>

@ -17,16 +17,21 @@
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.topic
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
@ -49,6 +54,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicA
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
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.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@ -97,45 +105,77 @@ internal fun TopicScreen(
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
TrackScrollJank(scrollableState = state, stateName = "topic:screen") TrackScrollJank(scrollableState = state, stateName = "topic:screen")
LazyColumn( Box(
state = state,
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
item { LazyColumn(
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) state = state,
} horizontalAlignment = Alignment.CenterHorizontally,
when (topicUiState) { ) {
TopicUiState.Loading -> item { item {
NiaLoadingWheel( Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
} }
when (topicUiState) {
TopicUiState.Loading -> item {
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
}
TopicUiState.Error -> TODO() TopicUiState.Error -> TODO()
is TopicUiState.Success -> { is TopicUiState.Success -> {
item { item {
TopicToolbar( TopicToolbar(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
uiState = topicUiState.followableTopic, uiState = topicUiState.followableTopic,
)
}
topicBody(
name = topicUiState.followableTopic.topic.name,
description = topicUiState.followableTopic.topic.longDescription,
news = newsUiState,
imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
) )
} }
topicBody( }
name = topicUiState.followableTopic.topic.name, item {
description = topicUiState.followableTopic.topic.longDescription, Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
news = newsUiState,
imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
)
} }
} }
item { val itemsAvailable = topicItemsSize(topicUiState, newsUiState)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) val scrollbarState = state.scrollbarState(
} itemsAvailable = itemsAvailable,
)
state.DraggableScrollbar(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 2.dp)
.align(Alignment.CenterEnd),
state = scrollbarState,
orientation = Orientation.Vertical,
onThumbMoved = state.rememberDraggableScroller(
itemsAvailable = itemsAvailable,
),
)
}
}
private fun topicItemsSize(
topicUiState: TopicUiState,
newsUiState: NewsUiState,
) = when (topicUiState) {
TopicUiState.Error -> 0 // Nothing
TopicUiState.Loading -> 1 // Loading bar
is TopicUiState.Success -> when (newsUiState) {
NewsUiState.Error -> 0 // Nothing
NewsUiState.Loading -> 1 // Loading bar
is NewsUiState.Success -> 2 + newsUiState.news.size // Toolbar, header
} }
} }

@ -23,7 +23,6 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQue
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
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.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -43,13 +42,12 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TopicViewModel @Inject constructor( class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository, topicsRepository: TopicsRepository,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() { ) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) private val topicArgs: TopicArgs = TopicArgs(savedStateHandle)
val topicId = topicArgs.topicId val topicId = topicArgs.topicId

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation package com.google.samples.apps.nowinandroid.feature.topic.navigation
import android.net.Uri
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController import androidx.navigation.NavController
@ -24,19 +23,23 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.text.Charsets.UTF_8
private val URL_CHARACTER_ENCODING = UTF_8.name()
@VisibleForTesting @VisibleForTesting
internal const val topicIdArg = "topicId" internal const val topicIdArg = "topicId"
internal class TopicArgs(val topicId: String) { internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) : constructor(savedStateHandle: SavedStateHandle) :
this(stringDecoder.decodeString(checkNotNull(savedStateHandle[topicIdArg]))) this(URLDecoder.decode(checkNotNull(savedStateHandle[topicIdArg]), URL_CHARACTER_ENCODING))
} }
fun NavController.navigateToTopic(topicId: String) { fun NavController.navigateToTopic(topicId: String) {
val encodedId = Uri.encode(topicId) val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
this.navigate("topic_route/$encodedId") { this.navigate("topic_route/$encodedId") {
launchSingleTop = true launchSingleTop = true
} }

@ -20,9 +20,7 @@ import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
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.testing.decoder.FakeStringDecoder
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -63,7 +61,6 @@ class TopicViewModelTest {
fun setup() { fun setup() {
viewModel = TopicViewModel( viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)), savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)),
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
@ -260,7 +257,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/-fJ6poHQrjM", url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = "Video 📺",
topics = listOf( topics = listOf(
Topic( Topic(
id = "0", id = "0",

@ -1,12 +1,12 @@
[versions] [versions]
accompanist = "0.28.0" accompanist = "0.28.0"
androidDesugarJdkLibs = "1.2.2" androidDesugarJdkLibs = "2.0.3"
androidGradlePlugin = "8.0.2" androidGradlePlugin = "8.1.0"
androidxActivity = "1.7.0" androidxActivity = "1.8.0-alpha06"
androidxAppCompat = "1.5.1" androidxAppCompat = "1.5.1"
androidxBrowser = "1.4.0" androidxBrowser = "1.4.0"
androidxComposeBom = "2023.06.01" androidxComposeBom = "2023.06.01"
androidxComposeCompiler = "1.4.8" androidxComposeCompiler = "1.5.0"
androidxComposeRuntimeTracing = "1.0.0-alpha03" androidxComposeRuntimeTracing = "1.0.0-alpha03"
androidxCore = "1.9.0" androidxCore = "1.9.0"
androidxCoreSplashscreen = "1.0.0" androidxCoreSplashscreen = "1.0.0"
@ -34,28 +34,27 @@ firebasePerfPlugin = "1.4.2"
gmsPlugin = "4.3.14" gmsPlugin = "4.3.14"
googleOss = "17.0.1" googleOss = "17.0.1"
googleOssPlugin = "0.10.6" googleOssPlugin = "0.10.6"
hilt = "2.46.1" hilt = "2.47"
hiltExt = "1.0.0" hiltExt = "1.0.0"
jacoco = "0.8.7" jacoco = "0.8.7"
junit4 = "4.13.2" junit4 = "4.13.2"
kotlin = "1.8.22" kotlin = "1.9.0"
kotlinxCoroutines = "1.6.4" kotlinxCoroutines = "1.6.4"
kotlinxDatetime = "0.4.0" kotlinxDatetime = "0.4.0"
kotlinxSerializationJson = "1.5.1" kotlinxSerializationJson = "1.5.1"
ksp = "1.8.22-1.0.11" ksp = "1.9.0-1.0.11"
lint = "30.3.1" lint = "31.0.2"
okhttp = "4.10.0" okhttp = "4.10.0"
protobuf = "3.23.0" protobuf = "3.23.4"
protobufPlugin = "0.9.3" protobufPlugin = "0.9.3"
retrofit = "2.9.0" retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "1.0.0" retrofitKotlinxSerializationJson = "1.0.0"
room = "2.5.0" room = "2.5.2"
secrets = "2.0.1" secrets = "2.0.1"
turbine = "0.12.1" turbine = "0.12.1"
[libraries] [libraries]
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" }
accompanist-testharness = { group = "com.google.accompanist", name = "accompanist-testharness", version.ref = "accompanist" } accompanist-testharness = { group = "com.google.accompanist", name = "accompanist-testharness", version.ref = "accompanist" }
android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }

@ -36,7 +36,10 @@ echo y | ${ANDROID_HOME}/tools/bin/sdkmanager --licenses
cd $KOKORO_ARTIFACTS_DIR/git/nowinandroid cd $KOKORO_ARTIFACTS_DIR/git/nowinandroid
# The build needs Java 17, set it as the default Java version. # The build needs Java 17, set it as the default Java version.
sudo update-java-alternatives --set java-1.17.0-openjdk-amd64 sudo apt-get update
sudo apt-get install -y openjdk-17-jdk
sudo update-alternatives --set java /usr/lib/jvm/java-17-openjdk-amd64/bin/java
java -version
# Also clear JAVA_HOME variable so java -version is used instead # Also clear JAVA_HOME variable so java -version is used instead
export JAVA_HOME= export JAVA_HOME=

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save