Merge branch 'main' into move-android-instrumented-test

pull/1048/head
Simon Marquis 1 year ago committed by GitHub
commit d17f3b1101
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,3 +4,4 @@
[*.{kt,kts}]
ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true
ktlint_function_naming_ignore_when_annotated_with=Composable, Test

@ -0,0 +1,26 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
registries: "*"
labels: [ "version update" ]
groups:
kotlin-ksp-compose:
patterns:
- "org.jetbrains.kotlin:*"
- "org.jetbrains.kotlin.jvm"
- "com.google.devtools.ksp"
- "androidx.compose.compiler:compiler"
open-pull-requests-limit: 10
registries:
maven-google:
type: "maven-repository"
url: "https://maven.google.com"
replaces-base: true

@ -1,16 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base", "group:all", ":dependencyDashboard", "schedule:daily"
],
"packageRules": [
{
"matchPackageNames": ["org.objenesis:objenesis"],
"allowedVersions": "<=2.6"
},
{
"matchPackageNames": ["com.google.protobuf"],
"allowedVersions": "<=0.8.19"
}
]
}

@ -31,7 +31,7 @@ jobs:
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
@ -39,6 +39,9 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Check build-logic
run: ./gradlew check -p build-logic
- name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
@ -72,7 +75,7 @@ jobs:
# Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots.
- name: Run local tests
if: always()
run: ./gradlew testDemoDebug testProdDebug :lint:test
run: ./gradlew testDemoDebug :lint:test
# Replace task exclusions with `-Pandroidx.baselineprofile.skipgeneration` when
# https://android-review.googlesource.com/c/platform/frameworks/support/+/2602790 landed in a
# release build
@ -88,14 +91,14 @@ jobs:
-x collectProdNonMinifiedBenchmarkBaselineProfile
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: APKs
path: '**/build/outputs/apk/**/*.apk'
- name: Upload test results (XML)
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/build/test-results/test*UnitTest/**.xml'
@ -105,7 +108,7 @@ jobs:
- name: Upload lint reports (HTML)
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: lint-reports
path: '**/build/reports/lint-results-*.html'
@ -128,7 +131,7 @@ jobs:
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
@ -151,7 +154,7 @@ jobs:
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: test-reports-${{ matrix.api-level }}
path: '**/build/reports/androidTests'

@ -21,7 +21,7 @@ jobs:
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17

@ -22,7 +22,7 @@ The app is currently in development. The `prodRelease` variant is [available on
**Now in Android** displays content from the
[Now in Android](https://developer.android.com/series/now-in-android) series. Users can browse for
links to recent videos, articles and other content. Users can also follow topics they are interested
in.
in, and be notified when new content is published which matches interests they are following.
## Screenshots
@ -109,12 +109,42 @@ Examples:
manipulate the state of the `Test` repository and verify the resulting behavior, instead of
checking that specific repository methods were called.
## Screenshot tests
To run the tests execute the following gradle tasks:
- `testDemoDebug` run all local tests against the `demoDebug` variant.
- `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant.
**Now In Android** uses [Roborazzi](https://github.com/takahirom/roborazzi) to do screenshot tests
of certain screens and components. To run these tests, run the `verifyRoborazziDemoDebug` or
`recordRoborazziDemoDebug` tasks. Note that screenshots are recorded on CI, using Linux, and other
platforms might generate slightly different images, making the tests fail.
**Note:** You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute
tests against _all_ build variants which is both unecessary and will result in failures as only the
`demoDebug` variant is supported. No other variants have any tests (although this might change in future).
## Screenshot tests
A screenshot test takes a screenshot of a screen or a UI component within the app, and compares it
with a previously recorded screenshot which is known to be rendered correctly.
For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemoDebug/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt)
to verify that the navigation is displayed correctly on different screen sizes
([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemoDebug/screenshots)).
Now In Android uses [Roborazzi](https://github.com/takahirom/roborazzi) to run screenshot tests
of certain screens and UI components. When working with screenshot tests the following gradle tasks are useful:
- `verifyRoborazziDemoDebug` run all screenshot tests, verifying the screenshots against the known
correct screenshots.
- `recordRoborazziDemoDebug` record new "known correct" screenshots. Use this command when you have
made changes to the UI and manually verified that they are rendered correctly. Screenshots will be
stored in `modulename/src/test/screenshots`.
- `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct
images. These can also be found in `modulename/src/test/screenshots`.
**Note:** The known correct screenshots stored in this repository are recorded on CI using Linux. Other
platforms may (and probably will) generate slightly different images, making the screenshot tests fail.
When working on a non-Linux platform, a workaround to this is to run `recordRoborazziDemoDebug` on the
`main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only
legitimate changes.
For more information about screenshot testing
[check out this talk](https://www.droidcon.com/2023/11/15/easy-screenshot-testing-with-compose/).
# UI
The app was designed using [Material 3 guidelines](https://m3.material.io/). Learn more about the design process and

@ -65,7 +65,12 @@ android {
}
dependencies {
implementation(libs.androidx.activity.compose)
implementation(projects.core.designsystem)
implementation(projects.core.ui)
implementation(libs.androidx.activity.compose)
}
dependencyGuard {
configuration("releaseRuntimeClasspath")
}

@ -0,0 +1,103 @@
androidx.activity:activity-compose:1.8.0
androidx.activity:activity-ktx:1.8.0
androidx.activity:activity:1.8.0
androidx.annotation:annotation-experimental:1.3.1
androidx.annotation:annotation-jvm:1.7.0
androidx.annotation:annotation:1.7.0
androidx.appcompat:appcompat-resources:1.6.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.6.0
androidx.collection:collection-jvm:1.3.0
androidx.collection:collection:1.3.0
androidx.compose.animation:animation-android:1.5.4
androidx.compose.animation:animation-core-android:1.5.4
androidx.compose.animation:animation-core:1.5.4
androidx.compose.animation:animation:1.5.4
androidx.compose.foundation:foundation-android:1.5.4
androidx.compose.foundation:foundation-layout-android:1.5.4
androidx.compose.foundation:foundation-layout:1.5.4
androidx.compose.foundation:foundation:1.5.4
androidx.compose.material3:material3:1.1.2
androidx.compose.material:material-icons-core-android:1.5.4
androidx.compose.material:material-icons-core:1.5.4
androidx.compose.material:material-icons-extended-android:1.5.4
androidx.compose.material:material-icons-extended:1.5.4
androidx.compose.material:material-ripple-android:1.5.4
androidx.compose.material:material-ripple:1.5.4
androidx.compose.runtime:runtime-android:1.5.4
androidx.compose.runtime:runtime-saveable-android:1.5.4
androidx.compose.runtime:runtime-saveable:1.5.4
androidx.compose.runtime:runtime:1.5.4
androidx.compose.ui:ui-android:1.5.4
androidx.compose.ui:ui-geometry-android:1.5.4
androidx.compose.ui:ui-geometry:1.5.4
androidx.compose.ui:ui-graphics-android:1.5.4
androidx.compose.ui:ui-graphics:1.5.4
androidx.compose.ui:ui-text-android:1.5.4
androidx.compose.ui:ui-text:1.5.4
androidx.compose.ui:ui-tooling-preview-android:1.5.4
androidx.compose.ui:ui-tooling-preview:1.5.4
androidx.compose.ui:ui-unit-android:1.5.4
androidx.compose.ui:ui-unit:1.5.4
androidx.compose.ui:ui-util-android:1.5.4
androidx.compose.ui:ui-util:1.5.4
androidx.compose.ui:ui:1.5.4
androidx.compose:compose-bom:2023.10.01
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core:1.12.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
androidx.emoji2:emoji2:1.4.0
androidx.exifinterface:exifinterface:1.3.6
androidx.fragment:fragment:1.5.1
androidx.interpolator:interpolator:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.2
androidx.lifecycle:lifecycle-common:2.6.2
androidx.lifecycle:lifecycle-livedata-core:2.6.2
androidx.lifecycle:lifecycle-livedata:2.6.2
androidx.lifecycle:lifecycle-process:2.6.2
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2
androidx.lifecycle:lifecycle-runtime:2.6.2
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2
androidx.lifecycle:lifecycle-viewmodel:2.6.2
androidx.loader:loader:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing:1.0.0
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.50
com.google.dagger:dagger:2.50
com.google.dagger:hilt-android:2.50
com.google.dagger:hilt-core:2.50
com.google.guava:listenablefuture:1.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.6.0
com.squareup.okio:okio:3.6.0
io.coil-kt:coil-base:2.5.0
io.coil-kt:coil-compose-base:2.5.0
io.coil-kt:coil-compose:2.5.0
io.coil-kt:coil:2.5.0
javax.inject:javax.inject:1
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0
org.jetbrains:annotations:23.0.0

@ -25,6 +25,7 @@ plugins {
alias(libs.plugins.nowinandroid.android.application.firebase)
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi)
}
android {
@ -96,47 +97,41 @@ dependencies {
implementation(projects.core.data)
implementation(projects.core.model)
implementation(projects.core.analytics)
implementation(projects.sync.work)
androidTestImplementation(projects.core.testing)
androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(projects.core.dataTest)
androidTestImplementation(projects.core.network)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(kotlin("test"))
debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(projects.uiTestHiltManifest)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.window.manager)
implementation(libs.androidx.profileinstaller)
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt)
baselineProfile(project(":benchmarks"))
debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(projects.uiTestHiltManifest)
kspTest(libs.hilt.compiler)
// Core functions
testImplementation(projects.core.testing)
testImplementation(projects.core.datastoreTest)
testImplementation(projects.core.dataTest)
testImplementation(projects.core.network)
testImplementation(libs.androidx.navigation.testing)
testImplementation(projects.core.testing)
testImplementation(libs.accompanist.testharness)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.work.testing)
testImplementation(kotlin("test"))
kspTest(libs.hilt.compiler)
testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi)
androidTestImplementation(projects.core.testing)
androidTestImplementation(projects.core.dataTest)
androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(libs.hilt.android.testing)
baselineProfile(projects.benchmarks)
}
baselineProfile {
@ -144,3 +139,7 @@ baselineProfile {
// Instead enable generation directly for the release build variant.
automaticGenerationDuringBuild = false
}
dependencyGuard {
configuration("prodReleaseRuntimeClasspath")
}

@ -0,0 +1,205 @@
androidx.activity:activity-compose:1.8.0
androidx.activity:activity-ktx:1.8.0
androidx.activity:activity:1.8.0
androidx.annotation:annotation-experimental:1.3.1
androidx.annotation:annotation-jvm:1.7.0
androidx.annotation:annotation:1.7.0
androidx.appcompat:appcompat-resources:1.6.1
androidx.appcompat:appcompat:1.6.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.6.0
androidx.collection:collection-jvm:1.3.0
androidx.collection:collection-ktx:1.3.0
androidx.collection:collection:1.3.0
androidx.compose.animation:animation-android:1.5.4
androidx.compose.animation:animation-core-android:1.5.4
androidx.compose.animation:animation-core:1.5.4
androidx.compose.animation:animation:1.5.4
androidx.compose.foundation:foundation-android:1.5.4
androidx.compose.foundation:foundation-layout-android:1.5.4
androidx.compose.foundation:foundation-layout:1.5.4
androidx.compose.foundation:foundation:1.5.4
androidx.compose.material3:material3-window-size-class:1.1.2
androidx.compose.material3:material3:1.1.2
androidx.compose.material:material-icons-core-android:1.5.4
androidx.compose.material:material-icons-core:1.5.4
androidx.compose.material:material-icons-extended-android:1.5.4
androidx.compose.material:material-icons-extended:1.5.4
androidx.compose.material:material-ripple-android:1.5.4
androidx.compose.material:material-ripple:1.5.4
androidx.compose.runtime:runtime-android:1.5.4
androidx.compose.runtime:runtime-saveable-android:1.5.4
androidx.compose.runtime:runtime-saveable:1.5.4
androidx.compose.runtime:runtime:1.5.4
androidx.compose.ui:ui-android:1.5.4
androidx.compose.ui:ui-geometry-android:1.5.4
androidx.compose.ui:ui-geometry:1.5.4
androidx.compose.ui:ui-graphics-android:1.5.4
androidx.compose.ui:ui-graphics:1.5.4
androidx.compose.ui:ui-text-android:1.5.4
androidx.compose.ui:ui-text:1.5.4
androidx.compose.ui:ui-tooling-preview-android:1.5.4
androidx.compose.ui:ui-tooling-preview:1.5.4
androidx.compose.ui:ui-unit-android:1.5.4
androidx.compose.ui:ui-unit:1.5.4
androidx.compose.ui:ui-util-android:1.5.4
androidx.compose.ui:ui-util:1.5.4
androidx.compose.ui:ui:1.5.4
androidx.compose:compose-bom:2023.10.01
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core-splashscreen:1.0.1
androidx.core:core:1.12.0
androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
androidx.datastore:datastore-core:1.0.0
androidx.datastore:datastore-preferences-core:1.0.0
androidx.datastore:datastore-preferences:1.0.0
androidx.datastore:datastore:1.0.0
androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.0.0
androidx.emoji2:emoji2-views-helper:1.4.0
androidx.emoji2:emoji2:1.4.0
androidx.exifinterface:exifinterface:1.3.6
androidx.fragment:fragment:1.5.1
androidx.hilt:hilt-common:1.1.0
androidx.hilt:hilt-navigation-compose:1.0.0
androidx.hilt:hilt-navigation:1.0.0
androidx.hilt:hilt-work:1.1.0
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.2
androidx.lifecycle:lifecycle-common:2.6.2
androidx.lifecycle:lifecycle-livedata-core:2.6.2
androidx.lifecycle:lifecycle-livedata:2.6.2
androidx.lifecycle:lifecycle-process:2.6.2
androidx.lifecycle:lifecycle-runtime-compose:2.6.2
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2
androidx.lifecycle:lifecycle-runtime:2.6.2
androidx.lifecycle:lifecycle-service:2.6.2
androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2
androidx.lifecycle:lifecycle-viewmodel:2.6.2
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.navigation:navigation-common-ktx:2.7.4
androidx.navigation:navigation-common:2.7.4
androidx.navigation:navigation-compose:2.7.4
androidx.navigation:navigation-runtime-ktx:2.7.4
androidx.navigation:navigation-runtime:2.7.4
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
androidx.profileinstaller:profileinstaller:1.3.1
androidx.resourceinspection:resourceinspection-annotation:1.0.1
androidx.room:room-common:2.6.1
androidx.room:room-ktx:2.6.1
androidx.room:room-runtime:2.6.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.sqlite:sqlite-framework:2.4.0
androidx.sqlite:sqlite:2.4.0
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.1.0
androidx.tracing:tracing:1.1.0
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window:window:1.0.0
androidx.work:work-runtime-ktx:2.9.0-rc01
androidx.work:work-runtime:2.9.0-rc01
com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.accompanist:accompanist-permissions:0.32.0
com.google.android.datatransport:transport-api:3.0.0
com.google.android.datatransport:transport-backend-cct:3.1.9
com.google.android.datatransport:transport-runtime:3.1.9
com.google.android.gms:play-services-ads-identifier:18.0.0
com.google.android.gms:play-services-base:18.0.1
com.google.android.gms:play-services-basement:18.1.0
com.google.android.gms:play-services-cloud-messaging:17.0.1
com.google.android.gms:play-services-measurement-api:21.4.0
com.google.android.gms:play-services-measurement-base:21.4.0
com.google.android.gms:play-services-measurement-impl:21.4.0
com.google.android.gms:play-services-measurement-sdk-api:21.4.0
com.google.android.gms:play-services-measurement-sdk:21.4.0
com.google.android.gms:play-services-measurement:21.4.0
com.google.android.gms:play-services-oss-licenses:17.0.1
com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.0.2
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.50
com.google.dagger:dagger:2.50
com.google.dagger:hilt-android:2.50
com.google.dagger:hilt-core:2.50
com.google.errorprone:error_prone_annotations:2.11.0
com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics-ktx:21.4.0
com.google.firebase:firebase-analytics:21.4.0
com.google.firebase:firebase-annotations:16.2.0
com.google.firebase:firebase-bom:32.4.0
com.google.firebase:firebase-common-ktx:20.4.2
com.google.firebase:firebase-common:20.4.2
com.google.firebase:firebase-components:17.1.5
com.google.firebase:firebase-config:21.5.0
com.google.firebase:firebase-crashlytics-ktx:18.5.0
com.google.firebase:firebase-crashlytics:18.5.0
com.google.firebase:firebase-datatransport:18.1.8
com.google.firebase:firebase-encoders-json:18.0.1
com.google.firebase:firebase-encoders-proto:16.0.0
com.google.firebase:firebase-encoders:17.0.0
com.google.firebase:firebase-iid-interop:17.1.0
com.google.firebase:firebase-installations-interop:17.1.1
com.google.firebase:firebase-installations:17.2.0
com.google.firebase:firebase-measurement-connector:19.0.0
com.google.firebase:firebase-messaging-ktx:23.3.0
com.google.firebase:firebase-messaging:23.3.0
com.google.firebase:firebase-perf-ktx:20.5.0
com.google.firebase:firebase-perf:20.5.0
com.google.firebase:firebase-sessions:1.1.0
com.google.firebase:protolite-well-known-types:18.0.0
com.google.guava:failureaccess:1.0.1
com.google.guava:guava:31.1-android
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:1.3
com.google.protobuf:protobuf-javalite:3.24.4
com.google.protobuf:protobuf-kotlin-lite:3.24.4
com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0
com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.6.0
com.squareup.okio:okio:3.6.0
com.squareup.retrofit2:retrofit:2.9.0
io.coil-kt:coil-base:2.5.0
io.coil-kt:coil-compose-base:2.5.0
io.coil-kt:coil-compose:2.5.0
io.coil-kt:coil-svg:2.5.0
io.coil-kt:coil:2.5.0
io.github.aakira:napier-android:1.4.1
io.github.aakira:napier:1.4.1
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0
org.jetbrains:annotations:23.0.0

@ -105,9 +105,9 @@ application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
application: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon=''
uses-library-not-required:'android.ext.adservices'
uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label=''
uses-feature: name='android.hardware.faketouch'
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'

@ -51,7 +51,7 @@ 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
import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR
import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
/**
@ -93,15 +93,15 @@ class NavigationTest {
ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) }
// The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you)
private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests)
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title)
private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_interests)
private val sampleTopic = "Headlines"
private val appName by composeTestRule.stringResource(R.string.app_name)
private val saved by composeTestRule.stringResource(BookmarksR.string.saved)
private val settings by composeTestRule.stringResource(SettingsR.string.top_app_bar_action_icon_description)
private val brand by composeTestRule.stringResource(SettingsR.string.brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text)
private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_title)
private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_top_app_bar_action_icon_description)
private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text)
@Before
fun setup() = hiltRule.inject()
@ -166,7 +166,10 @@ class NavigationTest {
composeTestRule.apply {
// GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown.
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
// TODO: Add top level destinations here, see b/226357686.
onNodeWithText(saved).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
onNodeWithText(interests).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
}

@ -15,6 +15,8 @@
limitations under the License.
-->
<resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#FFFFFF</color>
<color name="ic_launcher_foreground_tint">#FF006780</color>
</resources>

@ -15,6 +15,8 @@
limitations under the License.
-->
<resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#000000</color>
<color name="ic_launcher_foreground_tint">#FF006780</color>
</resources>

@ -15,6 +15,8 @@
limitations under the License.
-->
<resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#FFFFFF</color>
<color name="ic_launcher_foreground_tint">#FFA23F16</color>
</resources>

@ -15,6 +15,8 @@
limitations under the License.
-->
<resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#000000</color>
<color name="ic_launcher_foreground_tint">#FFA23F16</color>
</resources>

@ -179,7 +179,7 @@ class MainActivity : ComponentActivity() {
To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
If you don't do either of these steps, you might only see the profile status reported as
"enqueued for compilation" when running the sample locally.
*/
*/
withContext(Dispatchers.IO) {
val status = ProfileVerifier.getCompilationStatusAsync().await()
Log.d(TAG, "ProfileInstaller status code: ${status.profileInstallResultCode}")

@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
@ -41,7 +41,7 @@ fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute,
startDestination: String = FOR_YOU_ROUTE,
) {
val navController = appState.navController
NavHost(

@ -21,7 +21,7 @@ import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR
import com.google.samples.apps.nowinandroid.feature.search.R as searchR
/**
* Type for the top level destinations in the application. Each of these destinations
@ -37,19 +37,19 @@ enum class TopLevelDestination(
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.for_you,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved,
iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests,
iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests,
),
}

@ -183,11 +183,11 @@ fun NiaApp(
titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_navigation_icon_description,
id = settingsR.string.feature_settings_top_app_bar_navigation_icon_description,
),
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description,
id = settingsR.string.feature_settings_top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,

@ -33,11 +33,11 @@ import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksRoute
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -91,9 +91,9 @@ class NiaAppState(
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) {
forYouNavigationRoute -> FOR_YOU
bookmarksRoute -> BOOKMARKS
interestsRoute -> INTERESTS
FOR_YOU_ROUTE -> FOR_YOU
BOOKMARKS_ROUTE -> BOOKMARKS
INTERESTS_ROUTE -> INTERESTS
else -> null
}
@ -115,7 +115,7 @@ class NiaAppState(
* Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the
* route.
*/
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().asList()
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
/**
* The top level destinations that have unread news resources.

@ -65,7 +65,7 @@ import javax.inject.Inject
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// Configure Robolectric to use a very large screen size that can fit all of the test sizes.
// This allows enough room to render the content under test without clipping or scaling.
@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi", sdk = [33])
@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
@HiltAndroidTest
class NiaAppScreenSizesScreenshotTests {

@ -45,7 +45,7 @@ class ScrollTopicListPowerMetricsBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
private val categories = PowerCategory.values()
private val categories = PowerCategory.entries
.associateWith { PowerCategoryDisplayLevel.TOTAL }
@Test

@ -60,7 +60,8 @@ class StartupBenchmark {
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
iterations = 20, // More iterations result in higher statistical significance.
// More iterations result in higher statistical significance.
iterations = 20,
startupMode = COLD,
setupBlock = {
pressHome()

@ -41,6 +41,15 @@ dependencies {
compileOnly(libs.firebase.performance.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
implementation(libs.truth)
}
tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
}
gradlePlugin {

@ -24,8 +24,6 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
// Screenshot Tests
pluginManager.apply("io.github.takahirom.roborazzi")
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)

@ -15,10 +15,10 @@
*/
import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.google.samples.apps.nowinandroid.configureBadgingTasks
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin
@ -33,6 +33,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
apply("nowinandroid.android.lint")
apply("com.dropbox.dependency-guard")
}
extensions.configure<ApplicationExtension> {
@ -47,4 +48,4 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
}
}
}
}

@ -21,7 +21,6 @@ import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.kotlin
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
@ -39,27 +38,12 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
}
dependencies {
add("implementation", project(":core:model"))
add("implementation", project(":core:ui"))
add("implementation", project(":core:designsystem"))
add("implementation", project(":core:data"))
add("implementation", project(":core:common"))
add("implementation", project(":core:domain"))
add("implementation", project(":core:analytics"))
add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing"))
add("androidTestImplementation", kotlin("test"))
add("androidTestImplementation", project(":core:testing"))
add("implementation", libs.findLibrary("coil.kt").get())
add("implementation", libs.findLibrary("coil.kt.compose").get())
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("kotlinx.coroutines.android").get())
}
}
}

@ -30,8 +30,6 @@ class AndroidHiltConventionPlugin : Plugin<Project> {
dependencies {
"implementation"(libs.findLibrary("hilt.android").get())
"ksp"(libs.findLibrary("hilt.compiler").get())
"kspAndroidTest"(libs.findLibrary("hilt.compiler").get())
"kspTest"(libs.findLibrary("hilt.compiler").get())
}
}

@ -26,8 +26,6 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
// Screenshot Tests
pluginManager.apply("io.github.takahirom.roborazzi")
val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension)

@ -41,6 +41,9 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
defaultConfig.targetSdk = 34
configureFlavors(this)
configureGradleManagedDevices(this)
// The resource prefix is derived from the module name,
// so resources inside ":core:module1" must be prefixed with "core_module1_"
resourcePrefix = path.split("""\W""".toRegex()).drop(1).distinct().joinToString(separator = "_").lowercase() + "_"
}
extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this)
@ -48,9 +51,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
}
dependencies {
add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing"))
add("androidTestImplementation", kotlin("test"))
add("androidTestImplementation", project(":core:testing"))
}
}
}

@ -14,29 +14,25 @@
* limitations under the License.
*/
import com.google.devtools.ksp.gradle.KspExtension
import androidx.room.gradle.RoomExtension
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.process.CommandLineArgumentProvider
import java.io.File
class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("androidx.room")
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<KspExtension> {
extensions.configure<RoomExtension> {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
schemaDirectory("$projectDir/schemas")
}
dependencies {
@ -46,16 +42,4 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
}
}
}
/**
* https://issuetracker.google.com/issues/132245929
* [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
*/
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File,
) : CommandLineArgumentProvider {
override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}")
}
}

@ -41,11 +41,6 @@ internal fun Project.configureAndroidCompose(
val bom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(bom))
add("androidTestImplementation", platform(bom))
// Add ComponentActivity to debug manifest
add("debugImplementation", libs.findLibrary("androidx.compose.ui.testManifest").get())
// Screenshot Tests on JVM
add("testImplementation", libs.findLibrary("robolectric").get())
add("testImplementation", libs.findLibrary("roborazzi").get())
}
testOptions {

@ -20,34 +20,39 @@ import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.android.SdkConstants
import com.google.common.truth.Truth.assertWithMessage
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations
import java.io.File
import java.nio.file.Files
import javax.inject.Inject
@CacheableTask
abstract class GenerateBadgingTask : DefaultTask() {
@get:OutputFile
abstract val badging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val apk: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val aapt2Executable: RegularFileProperty
@ -68,6 +73,7 @@ abstract class GenerateBadgingTask : DefaultTask() {
}
}
@CacheableTask
abstract class CheckBadgingTask : DefaultTask() {
// In order for the task to be up-to-date when the inputs have not changed,
@ -76,9 +82,11 @@ abstract class CheckBadgingTask : DefaultTask() {
@get:OutputDirectory
abstract val output: DirectoryProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val goldenBadging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val generatedBadging: RegularFileProperty
@ -89,17 +97,12 @@ abstract class CheckBadgingTask : DefaultTask() {
@TaskAction
fun taskAction() {
if (
Files.mismatch(
goldenBadging.get().asFile.toPath(),
generatedBadging.get().asFile.toPath(),
) != -1L
) {
throw GradleException(
"Generated badging is different from golden badging! " +
"If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}",
)
}
assertWithMessage(
"Generated badging is different from golden badging! " +
"If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}",
)
.that(generatedBadging.get().asFile.readText())
.isEqualTo(goldenBadging.get().asFile.readText())
}
}

@ -83,10 +83,8 @@ private fun Project.configureKotlin() {
val warningsAsErrors: String? by project
allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
)
}
}

@ -30,7 +30,10 @@ import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) {
@ -62,10 +65,14 @@ internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtensio
}
}
@DisableCachingByDefault(because = "Prints output")
internal abstract class PrintApkLocationTask : DefaultTask() {
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputDirectory
abstract val apkFolder: DirectoryProperty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFiles
abstract val sources: ListProperty<Directory>

@ -32,10 +32,12 @@ buildscript {
// Lists all plugins used throughout the project without applying them.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.dependencyGuard) apply false
alias(libs.plugins.firebase.crashlytics) apply false
alias(libs.plugins.firebase.perf) apply false
alias(libs.plugins.gms) apply false
@ -43,4 +45,5 @@ plugins {
alias(libs.plugins.ksp) apply false
alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.secrets) apply false
alias(libs.plugins.room) apply false
}

@ -24,9 +24,8 @@ android {
}
dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx)
implementation(libs.firebase.analytics)
implementation(libs.kotlinx.coroutines.android)
prodImplementation(platform(libs.firebase.bom))
prodImplementation(libs.firebase.analytics)
}

@ -23,7 +23,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
internal abstract class AnalyticsModule {
@Binds
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper
}

@ -27,7 +27,7 @@ private const val TAG = "StubAnalyticsHelper"
* analytics events should be sent to a backend.
*/
@Singleton
class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {
internal class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) {
Log.d(TAG, "Received analytics event: $event")
}

@ -28,13 +28,15 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
internal abstract class AnalyticsModule {
@Binds
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper
companion object {
@Provides
@Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics }
fun provideFirebaseAnalytics(): FirebaseAnalytics {
return Firebase.analytics
}
}
}

@ -23,7 +23,7 @@ import javax.inject.Inject
/**
* Implementation of `AnalyticsHelper` which logs events to a Firebase backend.
*/
class FirebaseAnalyticsHelper @Inject constructor(
internal class FirebaseAnalyticsHelper @Inject constructor(
private val firebaseAnalytics: FirebaseAnalytics,
) : AnalyticsHelper {

@ -24,6 +24,6 @@ android {
}
dependencies {
implementation(libs.kotlinx.coroutines.android)
testImplementation(projects.core.testing)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
}

@ -34,7 +34,7 @@ annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object CoroutineScopesModule {
internal object CoroutineScopesModule {
@Provides
@Singleton
@ApplicationScope

@ -24,6 +24,6 @@ android {
dependencies {
api(projects.core.data)
implementation(projects.core.testing)
implementation(projects.core.common)
implementation(libs.hilt.android.testing)
}

@ -31,18 +31,16 @@ android {
}
dependencies {
api(projects.core.common)
api(projects.core.database)
api(projects.core.datastore)
api(projects.core.network)
implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.model)
implementation(projects.core.network)
implementation(projects.core.notifications)
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotlinx.serialization.json)
testImplementation(projects.core.datastoreTest)
testImplementation(projects.core.testing)
}

@ -35,35 +35,35 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface DataModule {
abstract class DataModule {
@Binds
fun bindsTopicRepository(
internal abstract fun bindsTopicRepository(
topicsRepository: OfflineFirstTopicsRepository,
): TopicsRepository
@Binds
fun bindsNewsResourceRepository(
internal abstract fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository,
): NewsRepository
@Binds
fun bindsUserDataRepository(
internal abstract fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
internal abstract fun bindsRecentSearchRepository(
recentSearchRepository: DefaultRecentSearchRepository,
): RecentSearchRepository
@Binds
fun bindsSearchContentsRepository(
internal abstract fun bindsSearchContentsRepository(
searchContentsRepository: DefaultSearchContentsRepository,
): SearchContentsRepository
@Binds
fun bindsNetworkMonitor(
internal abstract fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor,
): NetworkMonitor
}

@ -25,7 +25,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface UserNewsResourceRepositoryModule {
internal interface UserNewsResourceRepositoryModule {
@Binds
fun bindsUserNewsResourceRepository(
userDataRepository: CompositeUserNewsResourceRepository,

@ -20,7 +20,7 @@ import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) {
internal fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) {
val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved"
val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id"
logEvent(
@ -33,7 +33,7 @@ fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBoo
)
}
fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) {
internal fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) {
val eventType = if (isFollowed) "topic_followed" else "topic_unfollowed"
val paramKey = if (isFollowed) "followed_topic_id" else "unfollowed_topic_id"
logEvent(
@ -46,7 +46,7 @@ fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: B
)
}
fun AnalyticsHelper.logThemeChanged(themeName: String) =
internal fun AnalyticsHelper.logThemeChanged(themeName: String) =
logEvent(
AnalyticsEvent(
type = "theme_changed",
@ -56,7 +56,7 @@ fun AnalyticsHelper.logThemeChanged(themeName: String) =
),
)
fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
internal fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
logEvent(
AnalyticsEvent(
type = "dark_theme_config_changed",
@ -66,7 +66,7 @@ fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
),
)
fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
internal fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
logEvent(
AnalyticsEvent(
type = "dynamic_color_preference_changed",
@ -76,7 +76,7 @@ fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
),
)
fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {
internal fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {
val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset"
logEvent(
AnalyticsEvent(type = eventType),

@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import javax.inject.Inject
class DefaultRecentSearchRepository @Inject constructor(
internal class DefaultRecentSearchRepository @Inject constructor(
private val recentSearchQueryDao: RecentSearchQueryDao,
) : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import javax.inject.Inject
class DefaultSearchContentsRepository @Inject constructor(
internal class DefaultSearchContentsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val newsResourceFtsDao: NewsResourceFtsDao,
private val topicDao: TopicDao,

@ -45,7 +45,7 @@ private const val SYNC_BATCH_SIZE = 40
* Disk storage backed implementation of the [NewsRepository].
* Reads are exclusively from local storage to support offline access.
*/
class OfflineFirstNewsRepository @Inject constructor(
internal class OfflineFirstNewsRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,

@ -34,7 +34,7 @@ import javax.inject.Inject
* Disk storage backed implementation of the [TopicsRepository].
* Reads are exclusively from local storage to support offline access.
*/
class OfflineFirstTopicsRepository @Inject constructor(
internal class OfflineFirstTopicsRepository @Inject constructor(
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : TopicsRepository {

@ -25,7 +25,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class OfflineFirstUserDataRepository @Inject constructor(
internal class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository {

@ -33,7 +33,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
class ConnectivityManagerNetworkMonitor @Inject constructor(
internal class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context,
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {

@ -30,9 +30,8 @@ android {
}
dependencies {
implementation(projects.core.model)
api(projects.core.model)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
androidTestImplementation(projects.core.testing)

@ -28,7 +28,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object DaosModule {
internal object DaosModule {
@Provides
fun providesTopicsDao(
database: NiaDatabase,

@ -28,7 +28,7 @@ import androidx.room.migration.AutoMigrationSpec
* from and Y is the schema version you're migrating to. The class should implement
* `AutoMigrationSpec`.
*/
object DatabaseMigrations {
internal object DatabaseMigrations {
@RenameColumn(
tableName = "topics",

@ -27,7 +27,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
internal object DatabaseModule {
@Provides
@Singleton
fun providesNiaDatabase(

@ -63,7 +63,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
@TypeConverters(
InstantConverter::class,
)
abstract class NiaDatabase : RoomDatabase() {
internal abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao
abstract fun newsResourceDao(): NewsResourceDao
abstract fun topicFtsDao(): TopicFtsDao

@ -19,7 +19,7 @@ package com.google.samples.apps.nowinandroid.core.database.util
import androidx.room.TypeConverter
import kotlinx.datetime.Instant
class InstantConverter {
internal class InstantConverter {
@TypeConverter
fun longToInstant(value: Long?): Instant? =
value?.let(Instant::fromEpochMilliseconds)

@ -51,5 +51,5 @@ androidComponents.beforeVariants {
}
dependencies {
implementation(libs.protobuf.kotlin.lite)
api(libs.protobuf.kotlin.lite)
}

@ -23,10 +23,7 @@ android {
}
dependencies {
api(projects.core.datastore)
api(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.hilt.android.testing)
implementation(projects.core.common)
implementation(projects.core.testing)
implementation(projects.core.datastore)
}

@ -35,7 +35,7 @@ import javax.inject.Singleton
components = [SingletonComponent::class],
replaces = [DataStoreModule::class],
)
object TestDataStoreModule {
internal object TestDataStoreModule {
@Provides
@Singleton

@ -33,13 +33,12 @@ android {
}
dependencies {
api(libs.androidx.dataStore.core)
api(projects.core.datastoreProto)
api(projects.core.model)
implementation(projects.core.common)
implementation(projects.core.model)
implementation(libs.androidx.dataStore.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.protobuf.kotlin.lite)
testImplementation(projects.core.datastoreTest)
testImplementation(projects.core.testing)
testImplementation(libs.kotlinx.coroutines.test)
}

@ -21,7 +21,7 @@ import androidx.datastore.core.DataMigration
/**
* Migrates saved ids from [Int] to [String] types
*/
object IntToStringIdsMigration : DataMigration<UserPreferences> {
internal object IntToStringIdsMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit

@ -21,7 +21,7 @@ import androidx.datastore.core.DataMigration
/**
* Migrates from using lists to maps for user data.
*/
object ListToMapMigration : DataMigration<UserPreferences> {
internal object ListToMapMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit

@ -41,7 +41,7 @@ object DataStoreModule {
@Provides
@Singleton
fun providesUserPreferencesDataStore(
internal fun providesUserPreferencesDataStore(
@ApplicationContext context: Context,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
@ApplicationScope scope: CoroutineScope,

@ -17,6 +17,7 @@ plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.roborazzi)
}
android {
@ -39,8 +40,16 @@ dependencies {
debugApi(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt.compose)
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.androidx.compose.ui.testManifest)
testImplementation(libs.accompanist.testharness)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(projects.core.testing)
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}

@ -134,7 +134,7 @@ fun NiaOutlinedButton(
MaterialTheme.colorScheme.outline
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaButtonDefaults.DisabledOutlinedButtonBorderAlpha,
alpha = NiaButtonDefaults.DISABLED_OUTLINED_BUTTON_BORDER_ALPHA,
)
},
),
@ -278,7 +278,7 @@ fun NiaButtonPreview() {
@ThemePreviews
@Composable
fun NiaOutlinedButtonPreview() {
NiaTheme() {
NiaTheme {
NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) {
NiaOutlinedButton(onClick = {}, text = { Text("Test button") })
}
@ -315,7 +315,7 @@ fun NiaButtonLeadingIconPreview() {
object NiaButtonDefaults {
// TODO: File bug
// OutlinedButton border color doesn't respect disabled state by default
const val DisabledOutlinedButtonBorderAlpha = 0.12f
const val DISABLED_OUTLINED_BUTTON_BORDER_ALPHA = 0.12f
// TODO: File bug
// OutlinedButton default border width isn't exposed via ButtonDefaults

@ -76,10 +76,10 @@ fun NiaFilterChip(
borderColor = MaterialTheme.colorScheme.onBackground,
selectedBorderColor = MaterialTheme.colorScheme.onBackground,
disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha,
alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
),
disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha,
alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
),
selectedBorderWidth = NiaChipDefaults.ChipBorderWidth,
),
@ -88,16 +88,16 @@ fun NiaFilterChip(
iconColor = MaterialTheme.colorScheme.onBackground,
disabledContainerColor = if (selected) {
MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContainerAlpha,
alpha = NiaChipDefaults.DISABLED_CHIP_CONTAINER_ALPHA,
)
} else {
Color.Transparent
},
disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha,
alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
),
disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha,
alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
),
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onBackground,
@ -124,7 +124,7 @@ fun ChipPreview() {
object NiaChipDefaults {
// TODO: File bug
// FilterChip default values aren't exposed via FilterChipDefaults
const val DisabledChipContainerAlpha = 0.12f
const val DisabledChipContentAlpha = 0.38f
const val DISABLED_CHIP_CONTAINER_ALPHA = 0.12f
const val DISABLED_CHIP_CONTENT_ALPHA = 0.38f
val ChipBorderWidth = 1.dp
}

@ -49,7 +49,7 @@ fun DynamicAsyncImage(
imageUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter = painterResource(R.drawable.ic_placeholder_default),
placeholder: Painter = painterResource(R.drawable.core_designsystem_ic_placeholder_default),
) {
val iconTint = LocalTintTheme.current.iconTint
var isLoading by remember { mutableStateOf(true) }

@ -60,7 +60,7 @@ fun NiaIconToggleButton(
checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledContainerColor = if (checked) {
MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha,
alpha = NiaIconButtonDefaults.DISABLED_ICON_BUTTON_CONTAINER_ALPHA,
)
} else {
Color.Transparent
@ -123,5 +123,5 @@ fun IconButtonPreviewUnchecked() {
object NiaIconButtonDefaults {
// TODO: File bug
// IconToggleButton disabled container alpha not exposed by IconButtonDefaults
const val DisabledIconButtonContainerAlpha = 0.12f
const val DISABLED_ICON_BUTTON_CONTAINER_ALPHA = 0.12f
}

@ -40,7 +40,7 @@ fun NiaTopicTag(
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = NiaTagDefaults.UnfollowedTopicTagContainerAlpha,
alpha = NiaTagDefaults.UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA,
)
}
TextButton(
@ -50,7 +50,7 @@ fun NiaTopicTag(
containerColor = containerColor,
contentColor = contentColorFor(backgroundColor = containerColor),
disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaTagDefaults.DisabledTopicTagContainerAlpha,
alpha = NiaTagDefaults.DISABLED_TOPIC_TAG_CONTAINER_ALPHA,
),
),
) {
@ -75,9 +75,9 @@ fun TagPreview() {
* Now in Android tag default values.
*/
object NiaTagDefaults {
const val UnfollowedTopicTagContainerAlpha = 0.5f
const val UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA = 0.5f
// TODO: File bug
// Button disabled container alpha value not exposed by ButtonDefaults
const val DisabledTopicTagContainerAlpha = 0.12f
const val DISABLED_TOPIC_TAG_CONTAINER_ALPHA = 0.12f
}

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import android.annotation.SuppressLint
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
@ -38,12 +38,22 @@ 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.State
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.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.unit.LayoutDirection
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
@ -130,12 +140,7 @@ private fun ScrollableState.DraggableScrollbarThumb(
Horizontal -> height(12.dp).fillMaxWidth()
}
}
.background(
color = scrollbarThumbColor(
interactionSource = interactionSource,
),
shape = RoundedCornerShape(16.dp),
),
.scrollThumb(this, interactionSource),
)
}
@ -155,31 +160,72 @@ private fun ScrollableState.DecorativeScrollbarThumb(
Horizontal -> height(2.dp).fillMaxWidth()
}
}
.background(
color = scrollbarThumbColor(
interactionSource = interactionSource,
),
shape = RoundedCornerShape(16.dp),
),
.scrollThumb(this, interactionSource),
)
}
// TODO: This lint is removed in 1.6 as the recommendation has changed
// remove when project is upgraded
@SuppressLint("ComposableModifierFactory")
@Composable
private fun Modifier.scrollThumb(
scrollableState: ScrollableState,
interactionSource: InteractionSource,
): Modifier {
val colorState = scrollbarThumbColor(scrollableState, interactionSource)
return this then ScrollThumbElement { colorState.value }
}
private data class ScrollThumbElement(val colorProducer: ColorProducer) :
ModifierNodeElement<ScrollThumbNode>() {
override fun create(): ScrollThumbNode = ScrollThumbNode(colorProducer)
override fun update(node: ScrollThumbNode) {
node.colorProducer = colorProducer
node.invalidateDraw()
}
}
private class ScrollThumbNode(var colorProducer: ColorProducer) : DrawModifierNode, Modifier.Node() {
private val shape = RoundedCornerShape(16.dp)
// naive cache outline calculation if size is the same
private var lastSize: Size? = null
private var lastLayoutDirection: LayoutDirection? = null
private var lastOutline: Outline? = null
override fun ContentDrawScope.draw() {
val color = colorProducer()
val outline =
if (size == lastSize && layoutDirection == lastLayoutDirection) {
lastOutline!!
} else {
shape.createOutline(size, layoutDirection, this)
}
if (color != Color.Unspecified) drawOutline(outline, color = color)
lastOutline = outline
lastSize = size
lastLayoutDirection = layoutDirection
}
}
/**
* 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(
private fun scrollbarThumbColor(
scrollableState: ScrollableState,
interactionSource: InteractionSource,
): Color {
): State<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 active = (scrollableState.canScrollForward || scrollableState.canScrollBackward) &&
(pressed || hovered || dragged || scrollableState.isScrollInProgress)
val color by animateColorAsState(
val color = animateColorAsState(
targetValue = when (state) {
Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)
Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
@ -205,5 +251,7 @@ private fun ScrollableState.scrollbarThumbColor(
}
private enum class ThumbState {
Active, Inactive, Dormant
Active,
Inactive,
Dormant,
}

@ -16,8 +16,9 @@
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.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
@ -28,31 +29,28 @@ 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.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
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.runtime.snapshotFlow
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.Layout
import androidx.compose.ui.layout.layout
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
@ -61,6 +59,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll
@ -74,21 +73,59 @@ private const val SCROLLBAR_PRESS_DELAY_MS = 10L
*/
private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f
class ScrollbarState {
private var packedValue by mutableLongStateOf(0L)
internal fun onScroll(stateValue: ScrollbarStateValue) {
packedValue = stateValue.packedValue
}
/**
* Returns the thumb size of the scrollbar as a percentage of the total track size
*/
val thumbSizePercent
get() = unpackFloat1(packedValue)
/**
* Returns the distance the thumb has traveled as a percentage of total track size
*/
val thumbMovedPercent
get() = unpackFloat2(packedValue)
/**
* Returns the max distance the thumb can travel as a percentage of total track size
*/
val thumbTrackSizePercent
get() = 1f - thumbSizePercent
}
/**
* 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,
)
/**
* Class definition for the core properties of a scroll bar
*/
@Immutable
@JvmInline
value class ScrollbarState internal constructor(
value class ScrollbarStateValue 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
@ -105,54 +142,23 @@ private value class ScrollbarTrack(
}
/**
* Creates a [ScrollbarState] with the listed properties
* Creates a [ScrollbarStateValue] 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(
fun scrollbarStateValue(
thumbSizePercent: Float,
thumbMovedPercent: Float,
) = ScrollbarState(
) = ScrollbarStateValue(
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]
*/
@ -197,8 +203,6 @@ fun Scrollbar(
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) }
@ -210,23 +214,6 @@ fun Scrollbar(
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
@ -320,84 +307,113 @@ fun Scrollbar(
}
},
) {
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
Layout(content = { thumb() }) { measurables, constraints ->
val measurable = measurables.first()
val thumbSizePx = max(
a = state.thumbSizePercent * track.size,
b = minThumbSize.toPx(),
)
val trackSizePx = when (state.thumbTrackSizePercent) {
0f -> track.size
else -> (track.size - thumbSizePx) / state.thumbTrackSizePercent
}
val thumbTravelPercent = max(
a = min(
a = when {
interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent
else -> interactionThumbTravelPercent
},
b = state.thumbTrackSizePercent,
),
) {
thumb()
b = 0f,
)
val thumbMovedPx = trackSizePx * thumbTravelPercent
val y = when (orientation) {
Horizontal -> 0
Vertical -> thumbMovedPx.roundToInt()
}
val x = when (orientation) {
Horizontal -> thumbMovedPx.roundToInt()
Vertical -> 0
}
val updatedConstraints = when (orientation) {
Horizontal -> {
constraints.copy(
minWidth = thumbSizePx.roundToInt(),
maxWidth = thumbSizePx.roundToInt(),
)
}
Vertical -> {
constraints.copy(
minHeight = thumbSizePx.roundToInt(),
maxHeight = thumbSizePx.roundToInt(),
)
}
}
val placeable = measurable.measure(updatedConstraints)
layout(placeable.width, placeable.height) {
placeable.place(x, y)
}
}
}
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
}
LaunchedEffect(Unit) {
snapshotFlow { pressedOffset }.collect { pressedOffset ->
// Press ended, reset interactionThumbTravelPercent
if (pressedOffset == Offset.Unspecified) {
interactionThumbTravelPercent = Float.NaN
return@collect
}
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,
)
var currentThumbMovedPercent = state.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,
)
else -> max(
a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent,
)
}
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
}
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
}
}
// Process drags
LaunchedEffect(draggedOffset) {
if (draggedOffset == Offset.Unspecified) {
interactionThumbTravelPercent = Float.NaN
return@LaunchedEffect
LaunchedEffect(Unit) {
snapshotFlow { draggedOffset }.collect { draggedOffset ->
if (draggedOffset == Offset.Unspecified) {
interactionThumbTravelPercent = Float.NaN
return@collect
}
val currentTravel = track.thumbPosition(
dimension = orientation.valueOf(draggedOffset),
)
onThumbMoved(currentTravel)
interactionThumbTravelPercent = currentTravel
}
val currentTravel = track.thumbPosition(
dimension = orientation.valueOf(draggedOffset),
)
onThumbMoved(currentTravel)
interactionThumbTravelPercent = currentTravel
}
}

@ -24,7 +24,8 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@ -40,58 +41,58 @@ import kotlin.math.min
fun LazyListState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
): ScrollbarState = produceState(
initialValue = ScrollbarState.FULL,
key1 = this,
key2 = itemsAvailable,
) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { it.size },
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = itemInfo.size,
itemStartOffset = itemInfo.offset,
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { it.size },
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = itemInfo.size,
itemStartOffset = itemInfo.offset,
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
.filterNotNull()
.distinctUntilChanged()
.collect { value = it }
}.value
return state
}
/**
* Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]
@ -103,68 +104,68 @@ fun LazyListState.scrollbarState(
fun LazyGridState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,
): ScrollbarState = produceState(
initialValue = ScrollbarState.FULL,
key1 = this,
key2 = itemsAvailable,
) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
when (layoutInfo.orientation) {
Orientation.Vertical -> visibleItemsInfo.find {
it != first && it.row != first.row
}
Orientation.Horizontal -> visibleItemsInfo.find {
it != first && it.column != first.column
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
when (layoutInfo.orientation) {
Orientation.Vertical -> visibleItemsInfo.find {
it != first && it.row != first.row
}
Orientation.Horizontal -> visibleItemsInfo.find {
it != first && it.column != first.column
}
}
}
},
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
.filterNotNull()
.distinctUntilChanged()
.collect { value = it }
}.value
return state
}
/**
* Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState]
@ -177,57 +178,57 @@ fun LazyGridState.scrollbarState(
fun LazyStaggeredGridState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index,
): ScrollbarState = produceState(
initialValue = ScrollbarState.FULL,
key1 = this,
key2 = itemsAvailable,
) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
visibleItemsInfo.find { it != first && it.lane == first.lane }
},
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
visibleItemsInfo.find { it != first && it.lane == first.lane }
},
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = thumbTravelPercent,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = thumbTravelPercent,
)
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
.filterNotNull()
.distinctUntilChanged()
.collect { value = it }
}.value
return state
}
private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float {
var sum = 0f

@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import kotlin.math.roundToInt
/**
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState]
@ -79,7 +80,7 @@ private inline fun rememberDraggableScroller(
LaunchedEffect(percentage) {
if (percentage.isNaN()) return@LaunchedEffect
val indexToFind = (itemCount * percentage).toInt()
val indexToFind = (itemCount * percentage).roundToInt()
scroll(indexToFind)
}
return remember {

@ -36,7 +36,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class BackgroundScreenshotTests {

@ -36,7 +36,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class ButtonScreenshotTests {

@ -43,7 +43,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class FilterChipScreenshotTests() {

@ -35,7 +35,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class IconButtonScreenshotTests {

@ -37,7 +37,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class LoadingWheelScreenshotTests() {
@ -66,7 +66,7 @@ class LoadingWheelScreenshotTests() {
fun loadingWheelAnimation() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
NiaTheme() {
NiaTheme {
NiaLoadingWheel(contentDesc = "")
}
}

@ -44,7 +44,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class NavigationScreenshotTests() {

@ -42,7 +42,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class TabsScreenshotTests() {

@ -39,7 +39,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class TagScreenshotTests() {

@ -43,7 +43,7 @@ import org.robolectric.annotation.LooperMode
@OptIn(ExperimentalMaterial3Api::class)
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi")
@Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
class TopAppBarScreenshotTests() {

@ -24,13 +24,10 @@ android {
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.model)
implementation(libs.hilt.android)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
api(projects.core.data)
api(projects.core.model)
ksp(libs.hilt.compiler)
implementation(libs.javax.inject)
testImplementation(projects.core.testing)
}

@ -19,5 +19,5 @@ plugins {
}
dependencies {
implementation(libs.kotlinx.datetime)
api(libs.kotlinx.datetime)
}

@ -17,5 +17,7 @@
package com.google.samples.apps.nowinandroid.core.model.data
enum class DarkThemeConfig {
FOLLOW_SYSTEM, LIGHT, DARK
FOLLOW_SYSTEM,
LIGHT,
DARK,
}

@ -19,7 +19,8 @@ package com.google.samples.apps.nowinandroid.core.model.data
/**
* A [topic] with the additional information for whether or not it is followed.
*/
data class FollowableTopic( // TODO consider changing to UserTopic and flattening
// TODO consider changing to UserTopic and flattening
data class FollowableTopic(
val topic: Topic,
val isFollowed: Boolean,
)

@ -17,5 +17,6 @@
package com.google.samples.apps.nowinandroid.core.model.data
enum class ThemeBrand {
DEFAULT, ANDROID
DEFAULT,
ANDROID,
}

@ -39,16 +39,16 @@ secrets {
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.model)
api(libs.kotlinx.datetime)
api(projects.core.common)
api(projects.core.model)
implementation(libs.coil.kt)
implementation(libs.coil.kt.svg)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp.logging)
implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlin.serialization)
testImplementation(projects.core.testing)
testImplementation(libs.kotlinx.coroutines.test)
}

@ -25,7 +25,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface FlavoredNetworkModule {
internal interface FlavoredNetworkModule {
@Binds
fun binds(impl: FakeNiaNetworkDataSource): NiaNetworkDataSource

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

Loading…
Cancel
Save