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
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
@ -154,8 +154,8 @@ Run the following command to get and analyse compose compiler metrics:
./gradlew assembleRelease -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true
```
The reports files will be added to build/compose-reports in each module. The metrics files will be
added to build/compose-metrics in each module.
The reports files will be added to [build/compose-reports](build/compose-reports). The metrics files will also be
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).

@ -29,8 +29,8 @@ plugins {
android {
defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 5
versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level
versionCode = 8
versionName = "0.1.2" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
@ -106,7 +106,6 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(project(":ui-test-hilt-manifest"))
implementation(libs.accompanist.systemuicontroller)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
@ -122,12 +121,3 @@ dependencies {
implementation(libs.kotlinx.coroutines.guava)
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.SSLSocket
-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.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity
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 dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
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.Test
import org.junit.rules.TemporaryFolder
import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
@ -78,6 +86,9 @@ class NavigationTest {
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var topicsRepository: TopicsRepository
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) }
@ -92,6 +103,9 @@ class NavigationTest {
private val brand by composeTestRule.stringResource(SettingsR.string.brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text)
@Before
fun setup() = hiltRule.inject()
@Test
fun firstScreen_isForYou() {
composeTestRule.apply {
@ -251,11 +265,14 @@ class NavigationTest {
}
@Test
fun navigationBar_multipleBackStackInterests() {
fun navigationBar_multipleBackStackInterests() = runTest {
composeTestRule.apply {
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
onNodeWithText(forYou).performClick()
@ -264,7 +281,7 @@ class NavigationTest {
onNodeWithText(interests).performClick()
// 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
private fun rememberTestNavController(): TestNavHostController {
val context = LocalContext.current
val navController = remember {
return remember<TestNavHostController> {
TestNavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator())
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.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
@ -31,13 +33,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats
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.Success
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,
// including IME animations
WindowCompat.setDecorFitsSystemWindows(window, false)
// including IME animations, and go edge-to-edge
// This also sets up the initial system bar style based on the platform theme
enableEdgeToEdge()
setContent {
val systemUiController = rememberSystemUiController()
val darkTheme = shouldUseDarkTheme(uiState)
// Update the dark content of the system bars to match the theme
DisposableEffect(systemUiController, darkTheme) {
systemUiController.systemBarsDarkContentEnabled = !darkTheme
// Update the edge to edge configuration to match the theme
// This is the same parameters as the default enableEdgeToEdge call, but we manually
// 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 {}
}
@ -224,3 +236,15 @@ private fun shouldUseDarkTheme(
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 {
object Loading : MainActivityUiState
data object Loading : MainActivityUiState
data class Success(val userData: UserData) : MainActivityUiState
}

@ -16,10 +16,7 @@
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<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.Nia" parent="android:Theme.Material.NoActionBar" />
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<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
values-night folder. -->
<style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
</style>
<!-- Allows us to override platform level specific attributes in their
respective values-vXX folder. -->
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:statusBarColor">@color/black30</item>
</style>
<style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.Light.NoActionBar" />
<!-- 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">
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>

@ -14,4 +14,4 @@
See the License for the specific language governing permissions and
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
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
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.
@ -38,3 +41,24 @@ fun UiDevice.flingElementDownUp(element: UiObject2) {
waitForIdle()
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 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.foryou.forYouScrollFeedDownUp
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
// optimizing for app startup. But you can also navigate and scroll
// through your most important UI.
allowNotifications()
pressHome()
startActivityAndWait()

@ -21,13 +21,14 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp
import com.google.samples.apps.nowinandroid.waitAndFindObject
fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded by checking if topics are loaded
device.wait(Until.gone(By.res("loadingWheel")), 5_000)
// Sometimes, the loading wheel is gone, but the content is not loaded yet
// 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!
obj.wait(untilHasChildren(), 60_000)
}
@ -88,3 +89,17 @@ fun MacrobenchmarkScope.forYouScrollFeedDownUp() {
val feedList = device.findObject(By.res("forYou:feed"))
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()),
compilationMode = compilationMode,
iterations = 10,
startupMode = StartupMode.COLD,
startupMode = StartupMode.WARM,
setupBlock = {
// Start the app
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.Require
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
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.junit4.MacrobenchmarkRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import org.junit.Rule
import org.junit.Test
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
* for investigating your app's performance from a cold state.
*/
@RunWith(AndroidJUnit4ClassRunner::class)
class ColdStartupBenchmark : AbstractStartupBenchmark(COLD)
/**
* 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) {
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@ -80,12 +59,14 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
iterations = 10,
startupMode = startupMode,
startupMode = COLD,
setupBlock = {
pressHome()
allowNotifications()
},
) {
startActivityAndWait()
allowNotifications()
// Waits until the content is ready to capture Time To Full Display
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.configurePrintApksTask
import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@ -46,13 +45,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
configurePrintApksTask(this)
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 {
add("androidTestImplementation", kotlin("test"))
add("testImplementation", kotlin("test"))

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

@ -21,13 +21,12 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.File
/**
* Configure Compose-specific options
*/
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *>,
) {
commonExtension.apply {
buildFeatures {
@ -55,9 +54,11 @@ internal fun Project.configureAndroidCompose(
private fun Project.buildComposeMetricsParameters(): List<String> {
val metricParameters = mutableListOf<String>()
val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics")
val relativePath = projectDir.relativeTo(rootDir)
val enableMetrics = (enableMetricsProvider.orNull == "true")
if (enableMetrics) {
val metricsFolder = File(project.buildDir, "compose-metrics")
val metricsFolder = rootProject.buildDir.resolve("compose-metrics").resolve(relativePath)
metricParameters.add("-P")
metricParameters.add(
"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 enableReports = (enableReportsProvider.orNull == "true")
if (enableReports) {
val reportsFolder = File(project.buildDir, "compose-reports")
val reportsFolder = rootProject.buildDir.resolve("compose-reports").resolve(relativePath)
metricParameters.add("-P")
metricParameters.add(
"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
*/
internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *>,
) {
val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd")
val pixel6 = DeviceConfig("Pixel 6", 31, "aosp")

@ -30,7 +30,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
* Configure base Kotlin with Android options
*/
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *>,
) {
commonExtension.apply {
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.
*/
@Suppress("unused")
enum class NiaBuildType(val applicationIdSuffix: String? = null) {
DEBUG(".debug"),
RELEASE,

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

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

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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> {
data class Success<T>(val data: T) : Result<T>
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>> {

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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 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 {
hasOnboarded -> newsResourceDao.getNewsResources(
hasOnboarded -> newsResourceDao.getNewsResourceIds(
useFilterTopicIds = true,
filterTopicIds = followedTopicIds,
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(),
)
.first()
.map { it.entity.id }
.toSet()
// No need to retrieve anything if notifications won't be sent
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.NewsResourceQuery
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.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
@ -164,7 +163,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(sampleTopic1),
),
NewsResource(
@ -176,7 +175,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(sampleTopic1, sampleTopic2),
),
NewsResource(
@ -186,7 +185,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
type = "Video 📺",
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.FollowableTopic
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.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserData
@ -45,7 +44,7 @@ class UserNewsResourceTest {
url = "Test URL",
headerImageUrl = "Test image URL",
publishDate = Clock.System.now(),
type = Article,
type = "Article 📚",
topics = listOf(
Topic(
id = "T1",

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

@ -67,6 +67,33 @@ class TestNewsResourceDao : NewsResourceDao {
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(
entities: List<NewsResourceEntity>,
): List<Long> {

@ -17,7 +17,6 @@
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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.datetime.Instant
import org.junit.Test
@ -33,7 +32,7 @@ class PopulatedNewsResourceKtTest {
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
type = "Video 📺",
publishDate = Instant.fromEpochMilliseconds(1),
),
topics = listOf(
@ -56,7 +55,7 @@ class PopulatedNewsResourceKtTest {
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
type = "Video 📺",
publishDate = Instant.fromEpochMilliseconds(1),
topics = listOf(
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.TopicEntity
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.test.runTest
import kotlinx.datetime.Instant
@ -305,5 +304,5 @@ private fun testNewsResource(
url = "",
headerImageUrl = "",
publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch),
type = NewsResourceType.DAC,
type = "Article 📚",
)

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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.TopicFtsEntity
import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeConverter
@Database(
entities = [
@ -63,7 +62,6 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
)
@TypeConverters(
InstantConverter::class,
NewsResourceTypeConverter::class,
)
abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao

@ -65,6 +65,37 @@ interface NewsResourceDao {
filterNewsIds: Set<String> = emptySet(),
): 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
*/

@ -20,7 +20,6 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlinx.datetime.Instant
/**
@ -39,7 +38,7 @@ data class NewsResourceEntity(
val headerImageUrl: String?,
@ColumnInfo(name = "publish_date")
val publishDate: Instant,
val type: NewsResourceType,
val type: String,
)
fun NewsResourceEntity.asExternalModel() = NewsResource(

@ -17,8 +17,6 @@
package com.google.samples.apps.nowinandroid.core.database.util
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
class InstantConverter {
@ -30,13 +28,3 @@ class InstantConverter {
fun instantToLong(instant: Instant?): Long? =
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
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 {
implementation(project(":core:common"))
implementation(project(":core:model"))

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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
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
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.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.graphics.ColorFilter
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.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
/**
@ -31,14 +49,37 @@ fun DynamicAsyncImage(
imageUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
placeholder: Painter = painterResource(R.drawable.ic_placeholder_default),
) {
val iconTint = LocalTintTheme.current.iconTint
AsyncImage(
placeholder = placeholder,
var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) }
val imageLoader = rememberAsyncImagePainter(
model = imageUrl,
contentDescription = contentDescription,
colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null,
modifier = modifier,
onState = { state ->
isLoading = state is Loading
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,
modifier: Modifier = Modifier,
) {
val infiniteTransition = rememberInfiniteTransition()
val infiniteTransition = rememberInfiniteTransition(label = "wheel transition")
// Specifies the float animation for slowly drawing out the lines on entering
val startValue = if (LocalInspectionMode.current) 0F else 1F
@ -82,6 +82,7 @@ fun NiaLoadingWheel(
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing),
),
label = "wheel rotation animation",
)
// Specifies the color animation for the base-to-progress line color change
@ -100,6 +101,7 @@ fun NiaLoadingWheel(
repeatMode = RepeatMode.Restart,
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 headerImageUrl: String?,
val publishDate: Instant,
val type: NewsResourceType,
val type: String,
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 headerImageUrl: String?,
val publishDate: Instant,
val type: NewsResourceType,
val type: String,
val followableTopics: List<FollowableTopic>,
val isSaved: Boolean,
val hasBeenViewed: Boolean,

@ -17,9 +17,7 @@
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.NewsResourceType
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.serialization.Serializable
@ -35,8 +33,7 @@ data class NetworkNewsResource(
val headerImageUrl: String,
@Serializable(InstantSerializer::class)
val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class)
val type: NewsResourceType,
val type: String,
val topics: List<String> = listOf(),
)
@ -52,7 +49,6 @@ data class NetworkNewsResourceExpanded(
val headerImageUrl: String,
@Serializable(InstantSerializer::class)
val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class)
val type: NewsResourceType,
val type: String,
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>
}
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
private data class NetworkResponse<T>(
@ -77,7 +77,7 @@ class RetrofitNiaNetwork @Inject constructor(
) : NiaNetworkDataSource {
private val networkApi = Retrofit.Builder()
.baseUrl(NiaBaseUrl)
.baseUrl(NIA_BASE_URL)
.callFactory(okhttpCallFactory)
.addConverterFactory(
networkJson.asConverterFactory("application/json".toMediaType()),

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.network.fake
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.NetworkTopic
import kotlinx.coroutines.test.StandardTestDispatcher
@ -81,7 +80,7 @@ class FakeNiaNetworkDataSourceTest {
second = 0,
nanosecond = 0,
).toInstant(TimeZone.UTC),
type = Codelab,
type = "Codelab",
topics = listOf("2", "3", "10"),
),
/* 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
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",
name = "UI",
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",
url = "",
),

@ -17,9 +17,6 @@
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.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
/* 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",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Codelab,
type = "Codelab",
topics = listOf(topicsTestData[1]),
),
NewsResource(
@ -44,7 +41,7 @@ val newsResourcesTestData: List<NewsResource> = listOf(
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(topicsTestData[0], topicsTestData[1]),
),
NewsResource(
@ -57,7 +54,7 @@ val newsResourcesTestData: List<NewsResource> = listOf(
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(topicsTestData[2]),
),
NewsResource(
@ -68,7 +65,7 @@ val newsResourcesTestData: List<NewsResource> = listOf(
url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown,
type = "",
topics = listOf(topicsTestData[2]),
),
)

@ -32,7 +32,7 @@ val topicsTestData: List<Topic> = listOf(
id = "3",
name = "UI",
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",
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.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.UserData
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -56,7 +53,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
second = 0,
nanosecond = 0,
).toInstant(TimeZone.UTC),
type = Codelab,
type = "Codelab",
topics = listOf(topicsTestData[2]),
),
userData = userData,
@ -72,7 +69,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = topicsTestData.take(2),
),
userData = userData,
@ -88,7 +85,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(topicsTestData[2]),
),
userData = userData,
@ -102,7 +99,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown,
type = "",
topics = listOf(topicsTestData[2]),
),
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
fun testMetaDataDisplay_withCodelabResource() {
val newsWithKnownResourceType = userNewsResourcesTestData[0]
var dateFormatted = ""
lateinit var dateFormatted: String
composeTestRule.setContent {
NewsResourceCardExpanded(
@ -54,20 +54,20 @@ class NewsResourceCardTest {
composeTestRule.activity.getString(
R.string.card_meta_data_text,
dateFormatted,
newsWithKnownResourceType.type.displayText,
newsWithKnownResourceType.type,
),
)
.assertExists()
}
@Test
fun testMetaDataDisplay_withUnknownResource() {
val newsWithUnknownResourceType = userNewsResourcesTestData[3]
var dateFormatted = ""
fun testMetaDataDisplay_withEmptyResourceType() {
val newsWithEmptyResourceType = userNewsResourcesTestData[3]
lateinit var dateFormatted: String
composeTestRule.setContent {
NewsResourceCardExpanded(
userNewsResource = newsWithUnknownResourceType,
userNewsResource = newsWithEmptyResourceType,
isBookmarked = false,
hasBeenViewed = false,
onToggleBookmark = {},
@ -75,7 +75,7 @@ class NewsResourceCardTest {
onTopicClick = {},
)
dateFormatted = dateFormatted(publishDate = newsWithUnknownResourceType.publishDate)
dateFormatted = dateFormatted(publishDate = newsWithEmptyResourceType.publishDate)
}
composeTestRule

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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",
name = "UI",
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",
url = "",
),

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

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -31,6 +32,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
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.PreviewParameter
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.NiaTopicTag
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.model.data.FollowableTopic
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 kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
@ -72,7 +75,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
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
@ -147,21 +149,46 @@ fun NewsResourceCardExpanded(
fun NewsResourceHeaderImage(
headerImageUrl: String?,
) {
AsyncImage(
placeholder = if (LocalInspectionMode.current) {
painterResource(DesignsystemR.drawable.ic_placeholder_default)
} else {
// TODO b/228077205, show specific loading image visual
null
var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) }
val imageLoader = rememberAsyncImagePainter(
model = headerImageUrl,
onState = { state ->
isLoading = state is AsyncImagePainter.State.Loading
isError = state is AsyncImagePainter.State.Error
},
)
val isLocalInspection = LocalInspectionMode.current
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentScale = ContentScale.Crop,
model = headerImageUrl,
// TODO b/226661685: Investigate using alt text of image to populate content description
contentDescription = null, // decorative image
)
contentAlignment = Alignment.Center,
) {
if (isLoading) {
// 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
@ -241,12 +268,12 @@ fun dateFormatted(publishDate: Instant): String {
@Composable
fun NewsResourceMetaData(
publishDate: Instant,
resourceType: NewsResourceType,
resourceType: String,
) {
val formattedDate = dateFormatted(publishDate)
Text(
if (resourceType != NewsResourceType.Unknown) {
stringResource(R.string.card_meta_data_text, formattedDate, resourceType.displayText)
if (resourceType.isNotBlank()) {
stringResource(R.string.card_meta_data_text, formattedDate, resourceType)
} else {
formattedDate
},

@ -19,8 +19,6 @@ package com.google.samples.apps.nowinandroid.core.ui
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.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.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserData
@ -66,7 +64,7 @@ object PreviewParameterData {
id = "3",
name = "UI",
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",
url = "",
),
@ -97,7 +95,7 @@ object PreviewParameterData {
second = 0,
nanosecond = 0,
).toInstant(TimeZone.UTC),
type = NewsResourceType.Codelab,
type = "Codelab",
topics = listOf(topics[2]),
),
userData = userData,
@ -113,7 +111,7 @@ object PreviewParameterData {
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = topics.take(2),
),
userData = userData,
@ -129,7 +127,7 @@ object PreviewParameterData {
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(topics[2]),
),
userData = userData,

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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.compose.foundation.Image
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan
@ -57,6 +62,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -169,25 +177,49 @@ private fun BookmarksGrid(
) {
val scrollableState = rememberLazyGridState()
TrackScrollJank(scrollableState = scrollableState, stateName = "bookmarks:grid")
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
state = scrollableState,
Box(
modifier = modifier
.fillMaxSize()
.testTag("bookmarks:feed"),
.fillMaxSize(),
) {
newsFeed(
feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
)
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
state = scrollableState,
modifier = Modifier
.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
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.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells
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.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.NiaIconToggleButton
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.theme.NiaTheme
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.
ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading }
val itemsAvailable = feedItemsSize(feedState, onboardingUiState)
val state = rememberLazyGridState()
val scrollbarState = state.scrollbarState(
itemsAvailable = itemsAvailable,
)
TrackScrollJank(scrollableState = state, stateName = "forYou:feed")
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
Box(
modifier = modifier
.fillMaxSize()
.testTag("forYou:feed"),
state = state,
.fillMaxSize(),
) {
onboarding(
onboardingUiState = onboardingUiState,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics,
// 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)
}
},
)
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.testTag("forYou:feed"),
state = state,
) {
onboarding(
onboardingUiState = onboardingUiState,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics,
// 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(
feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
)
newsFeed(
feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
)
item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") {
Column {
Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar.
// TODO: Check that the Scaffold handles this correctly in NiaApp
// if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") {
Column {
Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar.
// TODO: Check that the Scaffold handles this correctly in NiaApp
// if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
}
AnimatedVisibility(
visible = isSyncing || isFeedLoading || isOnboardingLoading,
enter = slideInVertically(
initialOffsetY = { fullHeight -> -fullHeight },
) + fadeIn(),
exit = slideOutVertically(
targetOffsetY = { fullHeight -> -fullHeight },
) + fadeOut(),
) {
val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
AnimatedVisibility(
visible = isSyncing || isFeedLoading || isOnboardingLoading,
enter = slideInVertically(
initialOffsetY = { fullHeight -> -fullHeight },
) + fadeIn(),
exit = slideOutVertically(
targetOffsetY = { fullHeight -> -fullHeight },
) + fadeOut(),
) {
NiaOverlayLoadingWheel(
val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
modifier = Modifier
.align(Alignment.Center),
contentDesc = loadingContentDescription,
)
.fillMaxWidth()
.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")
NotificationPermissionEffect()
@ -298,42 +326,54 @@ private fun TopicSelection(
TrackScrollJank(scrollableState = lazyGridState, stateName = topicSelectionTestTag)
LazyHorizontalGrid(
state = lazyGridState,
rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.dp),
Box(
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),
.fillMaxWidth(),
) {
items(
items = onboardingUiState.topics,
key = { it.topic.id },
LazyHorizontalGrid(
state = lazyGridState,
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(
name = it.topic.name,
topicId = it.topic.id,
imageUrl = it.topic.imageUrl,
isSelected = it.isFollowed,
onClick = onTopicCheckedChanged,
)
items(
items = onboardingUiState.topics,
key = { it.topic.id },
) {
SingleTopicButton(
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
private fun SingleTopicButton(
name: String,
@ -394,7 +434,6 @@ fun TopicIcon(
modifier: Modifier = Modifier,
) {
DynamicAsyncImage(
// TODO b/228077205, show loading image visual instead of static placeholder
placeholder = painterResource(R.drawable.ic_icon_placeholder),
imageUrl = imageUrl,
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
@Composable
fun ForYouScreenPopulatedFeed(

@ -25,17 +25,17 @@ sealed interface OnboardingUiState {
/**
* The onboarding state is loading.
*/
object Loading : OnboardingUiState
data object Loading : OnboardingUiState
/**
* The onboarding state was unable to load.
*/
object LoadFailed : OnboardingUiState
data object LoadFailed : OnboardingUiState
/**
* There is no onboarding state.
*/
object NotShown : OnboardingUiState
data object NotShown : OnboardingUiState
/**
* 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.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
@ -545,7 +544,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(
Topic(
id = "0",
@ -566,7 +565,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(
Topic(
id = "1",
@ -585,7 +584,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(
Topic(
id = "1",

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
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.NiaIconToggleButton
@ -51,63 +49,46 @@ fun InterestsItem(
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
description: String = "",
itemSeparation: Dp = 16.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.clickable { onClick() }
.padding(vertical = itemSeparation),
) {
ListItem(
leadingContent = {
InterestsIcon(topicImageUrl, iconModifier.size(64.dp))
Spacer(modifier = Modifier.width(24.dp))
InterestContent(name, description)
}
NiaIconToggleButton(
checked = following,
onCheckedChange = onFollowButtonClick,
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = stringResource(
id = string.card_follow_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,
},
headlineContent = {
Text(text = name)
},
supportingContent = {
Text(text = description)
},
trailingContent = {
NiaIconToggleButton(
checked = following,
onCheckedChange = onFollowButtonClick,
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = stringResource(
id = string.card_follow_button_content_desc,
),
)
},
checkedIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = stringResource(
id = string.card_unfollow_button_content_desc,
),
)
},
)
}
}
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
modifier = modifier
.semantics(mergeDescendants = true) { /* no-op */ }
.clickable(enabled = true, onClick = onClick),
)
}
@Composable

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

@ -16,17 +16,28 @@
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.Spacer
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.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
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
@Composable
@ -37,30 +48,52 @@ fun TopicsTabContent(
modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true,
) {
LazyColumn(
Box(
modifier = modifier
.padding(horizontal = 24.dp)
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
.fillMaxWidth(),
) {
topics.forEach { followableTopic ->
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) },
)
val scrollableState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.padding(horizontal = 24.dp)
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
state = scrollableState,
) {
topics.forEach { followableTopic ->
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) {
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
if (withBottomSpacer) {
item {
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 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"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
this.navigate(interestsGraphRoutePattern, navOptions)
this.navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
}
fun NavGraphBuilder.interestsGraph(
@ -35,7 +35,7 @@ fun NavGraphBuilder.interestsGraph(
nestedGraphs: NavGraphBuilder.() -> Unit,
) {
navigation(
route = interestsGraphRoutePattern,
route = INTERESTS_GRAPH_ROUTE_PATTERN,
startDestination = interestsRoute,
) {
composable(route = interestsRoute) {

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

@ -14,4 +14,4 @@
See the License for the specific language governing permissions and
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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
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.hilt.navigation.compose.hiltViewModel
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.theme.NiaTheme
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.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.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@ -289,81 +297,102 @@ private fun SearchResultBody(
searchQuery: String = "",
) {
val state = rememberLazyGridState()
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
Box(
modifier = Modifier
.fillMaxSize()
.testTag("search:newsResources"),
state = state,
.fillMaxSize(),
) {
if (topics.isNotEmpty()) {
item(
span = {
GridItemSpan(maxLineSpan)
},
) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.topics))
}
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.fillMaxSize()
.testTag("search:newsResources"),
state = state,
) {
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(
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)
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.updates))
}
},
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
if (newsResources.isNotEmpty()) {
item(
span = {
GridItemSpan(maxLineSpan)
},
) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.updates))
}
newsFeed(
feedState = Success(feed = newsResources),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
onExpandedCardClick = {
onSearchTriggered(searchQuery)
},
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="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_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="interests">Interests</string>
<string name="to_browse_topics"> to browse topics</string>
@ -26,4 +26,4 @@
<string name="updates">Updates</string>
<string name="recent_searches">Recent 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) {
Column {
SettingsDialogSectionTitle(text = stringResource(R.string.dynamic_color_preference))
SettingsDialogSectionTitle(text = stringResource(string.dynamic_color_preference))
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
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()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.dark_mode_config_system_default),

@ -14,6 +14,4 @@
See the License for the specific language governing permissions and
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
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
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.NiaFilterChip
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.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@ -97,45 +105,77 @@ internal fun TopicScreen(
) {
val state = rememberLazyListState()
TrackScrollJank(scrollableState = state, stateName = "topic:screen")
LazyColumn(
state = state,
Box(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (topicUiState) {
TopicUiState.Loading -> item {
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
LazyColumn(
state = state,
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (topicUiState) {
TopicUiState.Loading -> item {
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
}
TopicUiState.Error -> TODO()
is TopicUiState.Success -> {
item {
TopicToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
uiState = topicUiState.followableTopic,
TopicUiState.Error -> TODO()
is TopicUiState.Success -> {
item {
TopicToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
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,
description = topicUiState.followableTopic.topic.longDescription,
news = newsUiState,
imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
)
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
val itemsAvailable = topicItemsSize(topicUiState, newsUiState)
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.UserDataRepository
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.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -43,13 +42,12 @@ import javax.inject.Inject
@HiltViewModel
class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder)
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle)
val topicId = topicArgs.topicId

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
@ -24,19 +23,23 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
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
internal const val topicIdArg = "topicId"
internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) :
this(stringDecoder.decodeString(checkNotNull(savedStateHandle[topicIdArg])))
constructor(savedStateHandle: SavedStateHandle) :
this(URLDecoder.decode(checkNotNull(savedStateHandle[topicIdArg]), URL_CHARACTER_ENCODING))
}
fun NavController.navigateToTopic(topicId: String) {
val encodedId = Uri.encode(topicId)
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
this.navigate("topic_route/$encodedId") {
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.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.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.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -63,7 +61,6 @@ class TopicViewModelTest {
fun setup() {
viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)),
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository,
@ -260,7 +257,7 @@ private val sampleNewsResources = listOf(
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
type = "Video 📺",
topics = listOf(
Topic(
id = "0",

@ -1,12 +1,12 @@
[versions]
accompanist = "0.28.0"
androidDesugarJdkLibs = "1.2.2"
androidGradlePlugin = "8.0.2"
androidxActivity = "1.7.0"
androidDesugarJdkLibs = "2.0.3"
androidGradlePlugin = "8.1.0"
androidxActivity = "1.8.0-alpha06"
androidxAppCompat = "1.5.1"
androidxBrowser = "1.4.0"
androidxComposeBom = "2023.06.01"
androidxComposeCompiler = "1.4.8"
androidxComposeCompiler = "1.5.0"
androidxComposeRuntimeTracing = "1.0.0-alpha03"
androidxCore = "1.9.0"
androidxCoreSplashscreen = "1.0.0"
@ -34,28 +34,27 @@ firebasePerfPlugin = "1.4.2"
gmsPlugin = "4.3.14"
googleOss = "17.0.1"
googleOssPlugin = "0.10.6"
hilt = "2.46.1"
hilt = "2.47"
hiltExt = "1.0.0"
jacoco = "0.8.7"
junit4 = "4.13.2"
kotlin = "1.8.22"
kotlin = "1.9.0"
kotlinxCoroutines = "1.6.4"
kotlinxDatetime = "0.4.0"
kotlinxSerializationJson = "1.5.1"
ksp = "1.8.22-1.0.11"
lint = "30.3.1"
ksp = "1.9.0-1.0.11"
lint = "31.0.2"
okhttp = "4.10.0"
protobuf = "3.23.0"
protobuf = "3.23.4"
protobufPlugin = "0.9.3"
retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "1.0.0"
room = "2.5.0"
room = "2.5.2"
secrets = "2.0.1"
turbine = "0.12.1"
[libraries]
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" }
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" }

@ -36,7 +36,10 @@ echo y | ${ANDROID_HOME}/tools/bin/sdkmanager --licenses
cd $KOKORO_ARTIFACTS_DIR/git/nowinandroid
# 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
export JAVA_HOME=

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

Loading…
Cancel
Save