diff --git a/.editorconfig b/.editorconfig index 83be1d5a0..7be3f8784 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties index bd989705d..dbafa68cd 100644 --- a/.github/ci-gradle.properties +++ b/.github/ci-gradle.properties @@ -19,7 +19,6 @@ org.gradle.parallel=true org.gradle.workers.max=2 kotlin.incremental=false -kotlin.compiler.execution.strategy=in-process # Controls KotlinOptions.allWarningsAsErrors. # This value used in CI and is currently set to false. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..433a7e4a3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +# 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: + patterns: + - "org.jetbrains.kotlin:*" + - "org.jetbrains.kotlin.jvm" + - "com.google.devtools.ksp" + open-pull-requests-limit: 10 +registries: + maven-google: + type: "maven-repository" + url: "https://maven.google.com" + replaces-base: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ed5fa237d..265ff2ba5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,17 +1,25 @@ -Thanks for submitting a pull request. Please include the following information. +**DO NOT CREATE A PULL REQUEST WITHOUT READING THESE INSTRUCTIONS** -**What I have done and why** -Include a summary of what your pull request contains, and why you have made these changes. +## Instructions +Thanks for submitting a pull request. To accept your pull request we need you do a few things: + +**If this is your first pull request** + +- [Sign the contributors license agreement](https://cla.developers.google.com/) + +**Ensure tests pass and code is formatted correctly** -Fixes # +- Run local tests on the `DemoDebug` variant by running `./gradlew testDemoDebug` +- Fix code formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply` -**Do tests pass?** -- [ ] Run local tests on `DemoDebug` variant: `./gradlew testDemoDebug` -- [ ] Check formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply` +**Add a description** -**Is this your first pull request?** -- [ ] [Sign the CLA](https://cla.developers.google.com/) -- [ ] Run `./tools/setup.sh` -- [ ] Import the code formatting style as explained in [the setup script](/tools/setup.sh#L40). +We need to know what you've done and why you've done it. Include a summary of what your pull request contains, and why you have made these changes. Include links to any relevant issues which it fixes. +[Here's an example](https://github.com/android/nowinandroid/pull/1257). + +**NOW DELETE THIS LINE AND EVERYTHING ABOVE IT** + +**What I have done and why** +\ diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index f19341761..000000000 --- a/.github/renovate.json +++ /dev/null @@ -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" - } - ] -} diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index ccea5dc4f..5bf03b47a 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -11,70 +11,67 @@ concurrency: cancel-in-progress: true jobs: - build: + test_and_apk: + name: "Local tests and APKs" runs-on: ubuntu-latest - timeout-minutes: 90 + + permissions: + contents: write + pull-requests: write + + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 - - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + 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 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - - - name: Check spotless - run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache - - - name: Build all build type and flavor permutations - run: ./gradlew assemble - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v3 + uses: gradle/actions/setup-gradle@v3 with: - name: APKs - path: '**/build/outputs/apk/**/*.apk' - - - name: Run local tests - run: ./gradlew testDemoDebug testProdDebug :lint:test - - test: - runs-on: ubuntu-latest + validate-wrappers: true + gradle-home-cache-cleanup: true - permissions: - contents: write + - name: Check build-logic + run: ./gradlew check -p build-logic - timeout-minutes: 60 + - name: Check spotless + run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache - steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Check Dependency Guard + id: dependencyguard_verify + continue-on-error: true + run: ./gradlew dependencyGuard - - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 + - name: Prevent updating Dependency Guard baselines if this is a fork + id: checkfork_dependencyguard + continue-on-error: false + if: steps.dependencyguard_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository + run: | + echo "::error::Dependency Guard failed, please update baselines with: ./gradlew dependencyGuardBaseline" && exit 1 - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + # Runs if previous job failed + - name: Generate new Dependency Guard baselines if verification failed and it's a PR + id: dependencyguard_baseline + if: steps.dependencyguard_verify.outcome == 'failure' && github.event_name == 'pull_request' + run: | + ./gradlew dependencyGuardBaseline - - name: Set up JDK 17 - uses: actions/setup-java@v3 + - name: Push new Dependency Guard baselines if available + uses: stefanzweifel/git-auto-commit-action@v5 + if: steps.dependencyguard_baseline.outcome == 'success' with: - distribution: 'zulu' - java-version: 17 - - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + file_pattern: '**/dependencies/*.txt' + disable_globbing: true + commit_message: "🤖 Updates baselines for Dependency Guard" - name: Run all local screenshot tests (Roborazzi) id: screenshotsverify @@ -82,7 +79,7 @@ jobs: run: ./gradlew verifyRoborazziDemoDebug - name: Prevent pushing new screenshots if this is a fork - id: checkfork + id: checkfork_screenshots continue-on-error: false if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository run: | @@ -104,23 +101,46 @@ jobs: commit_message: "🤖 Updates screenshots" # Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots. - - name: Run local tests + - name: Run local tests and create report if: always() - run: ./gradlew testDemoDebug testProdDebug + 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 + + - name: Build all build type and flavor permutations + run: ./gradlew :app:assemble :benchmarks:assemble + -x pixel6Api33ProdNonMinifiedReleaseAndroidTest + -x pixel6Api33DemoNonMinifiedReleaseAndroidTest + -x collectDemoNonMinifiedReleaseBaselineProfile + -x collectProdNonMinifiedReleaseBaselineProfile + + - name: Upload build outputs (APKs) + uses: actions/upload-artifact@v4 + with: + name: APKs + path: '**/build/outputs/apk/**/*.apk' - - name: Upload test results (XML) + - name: Upload JVM local results (XML) if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: test-results + name: local-test-results path: '**/build/test-results/test*UnitTest/**.xml' + - name: Upload screenshot results (PNG) + if: always() + uses: actions/upload-artifact@v4 + with: + name: screenshot-test-results + path: '**/build/outputs/roborazzi/*_compare.png' + - name: Check lint run: ./gradlew :app:lintProdRelease :app-nia-catalog:lintRelease :lint:lint - 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' @@ -129,14 +149,31 @@ jobs: run: ./gradlew :app:checkProdReleaseBadging androidTest: - needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine + runs-on: ubuntu-latest timeout-minutes: 55 strategy: matrix: api-level: [26, 30] steps: + - name: Delete unnecessary tools 🔧 + uses: jlumbroso/free-disk-space@v1.3.1 + with: + android: false # Don't remove Android tools + tool-cache: true # Remove image tool cache - rm -rf "$AGENT_TOOLSDIRECTORY" + dotnet: true # rm -rf /usr/share/dotnet + haskell: true # rm -rf /opt/ghc... + swap-storage: true # rm -f /mnt/swapfile (4GiB) + docker-images: false # Takes 16s, enable if needed in the future + large-packages: false # includes google-cloud-sdk and it's slow + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls /dev/kvm + - name: Checkout uses: actions/checkout@v4 @@ -144,18 +181,18 @@ 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 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - - - name: Build AndroidTest apps - run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest --daemon + uses: gradle/actions/setup-gradle@v3 + with: + validate-wrappers: true + gradle-home-cache-cleanup: true - - name: Run instrumentation tests + - name: Build projects and run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} @@ -165,9 +202,41 @@ jobs: heap-size: 600M script: ./gradlew connectedDemoDebugAndroidTest --daemon + - name: Run local tests (including Roborazzi) for the combined coverage report (only API 30) + if: matrix.api-level == 30 + # There is no need to verify Roborazzi tests to generate coverage. + run: ./gradlew testDemoDebugUnitTest -Proborazzi.test.verify=false # Add Prod if we ever add JVM tests for prod + + # Add `createProdDebugUnitTestCoverageReport` if we ever add JVM tests for prod + - name: Generate coverage reports for Debug variants (only API 30) + if: matrix.api-level == 30 + run: ./gradlew createDemoDebugCombinedCoverageReport + - 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' + + - name: Display local test coverage (only API 30) + if: matrix.api-level == 30 + id: jacoco + uses: madrapps/jacoco-report@v1.6.1 + with: + title: Combined test coverage report + min-coverage-overall: 40 + min-coverage-changed-files: 60 + paths: | + ${{ github.workspace }}/**/build/reports/jacoco/**/*Report.xml + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload local coverage reports (XML + HTML) (only API 30) + if: matrix.api-level == 30 + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + if-no-files-found: error + compression-level: 1 + overwrite: false + path: '**/build/reports/jacoco/' diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 96285d10e..b18b41faa 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -8,27 +8,48 @@ on: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 120 steps: + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls /dev/kvm + - name: Checkout - uses: actions/checkout@v4 - - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 + uses: actions/checkout@v4 - name: Copy CI gradle.properties 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 - - name: Build app - run: ./gradlew :app:assembleDemoRelease + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + validate-wrappers: true + gradle-home-cache-cleanup: true + - name: Install GMD image for baseline profile generation + run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager "system-images;android-33;aosp_atd;x86_64" + + - name: Accept Android licenses + run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true + + - name: Build release variant including baseline profile generation + run: ./gradlew :app:assembleDemoRelease + -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile + -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" + -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true + -Pandroid.experimental.androidTest.numManagedDeviceShards=1 + -Pandroid.experimental.testOptions.managedDevices.maxConcurrentDevices=1 - name: Create Release id: create_release uses: actions/create-release@v1 diff --git a/.gitignore b/.gitignore index d4482596d..cc7ae83f7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ _sandbox # Android Studio captures folder captures/ + +# Kotlin +.kotlin diff --git a/README.md b/README.md index b71427dfe..be1270b16 100644 --- a/README.md +++ b/README.md @@ -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/testDemo/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/testDemo/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 diff --git a/app-nia-catalog/README.md b/app-nia-catalog/README.md index edbbb5e46..cf6d05f4f 100644 --- a/app-nia-catalog/README.md +++ b/app-nia-catalog/README.md @@ -1,3 +1,3 @@ # :app-nia-catalog module - -![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.png) +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.svg) diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index a90da1604..94d55b81c 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -59,13 +59,18 @@ android { // To publish on the Play store a private signing key is required, but to allow anyone // who clones the code to sign and run the release variant, use the debug signing key. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.named("debug").get() } } } dependencies { + implementation(libs.androidx.activity.compose) + implementation(projects.core.designsystem) implementation(projects.core.ui) - implementation(libs.androidx.activity.compose) +} + +dependencyGuard { + configuration("releaseRuntimeClasspath") } diff --git a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt new file mode 100644 index 000000000..b9135ed42 --- /dev/null +++ b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt @@ -0,0 +1,122 @@ +androidx.activity:activity-compose:1.8.2 +androidx.activity:activity-ktx:1.8.2 +androidx.activity:activity:1.8.2 +androidx.annotation:annotation-experimental:1.4.0 +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.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.8.0 +androidx.collection:collection-jvm:1.4.0 +androidx.collection:collection-ktx:1.4.0 +androidx.collection:collection:1.4.0 +androidx.compose.animation:animation-android:1.7.0-beta01 +androidx.compose.animation:animation-core-android:1.7.0-beta01 +androidx.compose.animation:animation-core:1.7.0-beta01 +androidx.compose.animation:animation:1.7.0-beta01 +androidx.compose.foundation:foundation-android:1.7.0-beta01 +androidx.compose.foundation:foundation-layout-android:1.7.0-beta01 +androidx.compose.foundation:foundation-layout:1.7.0-beta01 +androidx.compose.foundation:foundation:1.7.0-beta01 +androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01 +androidx.compose.material3.adaptive:adaptive:1.0.0-beta01 +androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01 +androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01 +androidx.compose.material3:material3-android:1.3.0-beta01 +androidx.compose.material3:material3:1.3.0-beta01 +androidx.compose.material:material-icons-core-android:1.6.3 +androidx.compose.material:material-icons-core:1.6.3 +androidx.compose.material:material-icons-extended-android:1.6.3 +androidx.compose.material:material-icons-extended:1.6.3 +androidx.compose.material:material-ripple-android:1.7.0-beta01 +androidx.compose.material:material-ripple:1.7.0-beta01 +androidx.compose.runtime:runtime-android:1.7.0-beta01 +androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01 +androidx.compose.runtime:runtime-saveable:1.7.0-beta01 +androidx.compose.runtime:runtime:1.7.0-beta01 +androidx.compose.ui:ui-android:1.7.0-beta01 +androidx.compose.ui:ui-geometry-android:1.7.0-beta01 +androidx.compose.ui:ui-geometry:1.7.0-beta01 +androidx.compose.ui:ui-graphics-android:1.7.0-beta01 +androidx.compose.ui:ui-graphics:1.7.0-beta01 +androidx.compose.ui:ui-text-android:1.7.0-beta01 +androidx.compose.ui:ui-text:1.7.0-beta01 +androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01 +androidx.compose.ui:ui-tooling-preview:1.7.0-beta01 +androidx.compose.ui:ui-unit-android:1.7.0-beta01 +androidx.compose.ui:ui-unit:1.7.0-beta01 +androidx.compose.ui:ui-util-android:1.7.0-beta01 +androidx.compose.ui:ui-util:1.7.0-beta01 +androidx.compose.ui:ui:1.7.0-beta01 +androidx.compose:compose-bom:2024.02.02 +androidx.concurrent:concurrent-futures:1.1.0 +androidx.core:core-ktx:1.13.1 +androidx.core:core:1.13.1 +androidx.customview:customview-poolingcontainer:1.0.0 +androidx.customview:customview:1.0.0 +androidx.emoji2:emoji2:1.3.0 +androidx.exifinterface:exifinterface:1.3.7 +androidx.fragment:fragment:1.5.1 +androidx.graphics:graphics-path:1.0.1 +androidx.interpolator:interpolator:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.8.0 +androidx.lifecycle:lifecycle-common-jvm:2.8.0 +androidx.lifecycle:lifecycle-common:2.8.0 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.0 +androidx.lifecycle:lifecycle-livedata-core:2.8.0 +androidx.lifecycle:lifecycle-livedata:2.8.0 +androidx.lifecycle:lifecycle-process:2.8.0 +androidx.lifecycle:lifecycle-runtime-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-compose-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-compose:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx:2.8.0 +androidx.lifecycle:lifecycle-runtime:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-android:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0 +androidx.lifecycle:lifecycle-viewmodel:2.8.0 +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-ktx:1.3.0-alpha02 +androidx.tracing:tracing:1.3.0-alpha02 +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.extensions.core:core:1.0.0 +androidx.window:window-core-android:1.3.0-beta02 +androidx.window:window-core:1.3.0-beta02 +androidx.window:window:1.3.0-beta02 +com.google.accompanist:accompanist-drawablepainter:0.32.0 +com.google.code.findbugs:jsr305:3.0.2 +com.google.dagger:dagger-lint-aar:2.51.1 +com.google.dagger:dagger:2.51.1 +com.google.dagger:hilt-android:2.51.1 +com.google.dagger:hilt-core:2.51.1 +com.google.guava:listenablefuture:1.0 +com.squareup.okhttp3:okhttp:4.12.0 +com.squareup.okio:okio-jvm:3.8.0 +com.squareup.okio:okio:3.8.0 +io.coil-kt:coil-base:2.6.0 +io.coil-kt:coil-compose-base:2.6.0 +io.coil-kt:coil-compose:2.6.0 +io.coil-kt:coil:2.6.0 +javax.inject:javax.inject:1 +org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 +org.jetbrains.kotlin:kotlin-stdlib:2.0.0 +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 diff --git a/app/README.md b/app/README.md index 9f151c245..a3fb4572a 100644 --- a/app/README.md +++ b/app/README.md @@ -1,3 +1,3 @@ # :app module - -![Dependency graph](../docs/images/graphs/dep_graph_app.png) +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_app.svg) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1fd5e27a6..47b8af943 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,9 +21,10 @@ plugins { alias(libs.plugins.nowinandroid.android.application.flavors) alias(libs.plugins.nowinandroid.android.application.jacoco) alias(libs.plugins.nowinandroid.android.hilt) - id("jacoco") alias(libs.plugins.nowinandroid.android.application.firebase) id("com.google.android.gms.oss-licenses-plugin") + alias(libs.plugins.baselineprofile) + alias(libs.plugins.roborazzi) } android { @@ -43,7 +44,7 @@ android { debug { applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix } - val release by getting { + release { isMinifyEnabled = true applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") @@ -51,18 +52,9 @@ android { // To publish on the Play store a private signing key is required, but to allow anyone // who clones the code to sign and run the release variant, use the debug signing key. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. - signingConfig = signingConfigs.getByName("debug") - } - create("benchmark") { - // Enable all the optimizations from release build through initWith(release). - initWith(release) - matchingFallbacks.add("release") - // Debug key signing is available to everyone. - signingConfig = signingConfigs.getByName("debug") - // Only use benchmark proguard rules - proguardFiles("benchmark-rules.pro") - isMinifyEnabled = true - applicationIdSuffix = NiaBuildType.BENCHMARK.applicationIdSuffix + signingConfig = signingConfigs.named("debug").get() + // Ensure Baseline Profile is fresh for release builds. + baselineProfile.automaticGenerationDuringBuild = true } } @@ -93,43 +85,58 @@ 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.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.adaptive.layout) + implementation(libs.androidx.compose.material3.adaptive.navigation) + implementation(libs.androidx.compose.material3.windowSizeClass) + implementation(libs.androidx.compose.runtime.tracing) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) - implementation(libs.androidx.compose.runtime) - 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.lifecycle.runtimeCompose) implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.window.manager) implementation(libs.androidx.profileinstaller) + implementation(libs.androidx.tracing.ktx) + implementation(libs.androidx.window.core) implementation(libs.kotlinx.coroutines.guava) implementation(libs.coil.kt) - // Core functions - testImplementation(projects.core.testing) - testImplementation(projects.core.datastoreTest) + ksp(libs.hilt.compiler) + + debugImplementation(libs.androidx.compose.ui.testManifest) + debugImplementation(projects.uiTestHiltManifest) + + kspTest(libs.hilt.compiler) + testImplementation(projects.core.dataTest) - testImplementation(projects.core.network) - testImplementation(libs.androidx.navigation.testing) - testImplementation(libs.accompanist.testharness) - testImplementation(libs.work.testing) - testImplementation(kotlin("test")) - kaptTest(libs.hilt.compiler) + testImplementation(libs.hilt.android.testing) + testImplementation(projects.sync.syncTest) + + testDemoImplementation(libs.robolectric) + testDemoImplementation(libs.roborazzi) + testDemoImplementation(projects.core.screenshotTesting) + + androidTestImplementation(kotlin("test")) + androidTestImplementation(projects.core.testing) + androidTestImplementation(projects.core.dataTest) + androidTestImplementation(projects.core.datastoreTest) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.navigation.testing) + androidTestImplementation(libs.androidx.compose.ui.test) + androidTestImplementation(libs.hilt.android.testing) + + baselineProfile(projects.benchmarks) +} + +baselineProfile { + // Don't build on every iteration of a full assemble. + // Instead enable generation directly for the release build variant. + automaticGenerationDuringBuild = false +} +dependencyGuard { + configuration("prodReleaseRuntimeClasspath") } diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt new file mode 100644 index 000000000..d3e90da35 --- /dev/null +++ b/app/dependencies/prodReleaseRuntimeClasspath.txt @@ -0,0 +1,226 @@ +androidx.activity:activity-compose:1.8.2 +androidx.activity:activity-ktx:1.8.2 +androidx.activity:activity:1.8.2 +androidx.annotation:annotation-experimental:1.4.0 +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 +androidx.appcompat:appcompat-resources:1.7.0 +androidx.appcompat:appcompat:1.7.0 +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.8.0 +androidx.collection:collection-jvm:1.4.0 +androidx.collection:collection-ktx:1.4.0 +androidx.collection:collection:1.4.0 +androidx.compose.animation:animation-android:1.7.0-beta01 +androidx.compose.animation:animation-core-android:1.7.0-beta01 +androidx.compose.animation:animation-core:1.7.0-beta01 +androidx.compose.animation:animation:1.7.0-beta01 +androidx.compose.foundation:foundation-android:1.7.0-beta01 +androidx.compose.foundation:foundation-layout-android:1.7.0-beta01 +androidx.compose.foundation:foundation-layout:1.7.0-beta01 +androidx.compose.foundation:foundation:1.7.0-beta01 +androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01 +androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-beta01 +androidx.compose.material3.adaptive:adaptive-layout:1.0.0-beta01 +androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0-beta01 +androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-beta01 +androidx.compose.material3.adaptive:adaptive:1.0.0-beta01 +androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01 +androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01 +androidx.compose.material3:material3-android:1.3.0-beta01 +androidx.compose.material3:material3-window-size-class-android:1.3.0-beta01 +androidx.compose.material3:material3-window-size-class:1.3.0-beta01 +androidx.compose.material3:material3:1.3.0-beta01 +androidx.compose.material:material-icons-core-android:1.6.3 +androidx.compose.material:material-icons-core:1.6.3 +androidx.compose.material:material-icons-extended-android:1.6.3 +androidx.compose.material:material-icons-extended:1.6.3 +androidx.compose.material:material-ripple-android:1.7.0-beta01 +androidx.compose.material:material-ripple:1.7.0-beta01 +androidx.compose.runtime:runtime-android:1.7.0-beta01 +androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01 +androidx.compose.runtime:runtime-saveable:1.7.0-beta01 +androidx.compose.runtime:runtime-tracing:1.0.0-beta01 +androidx.compose.runtime:runtime:1.7.0-beta01 +androidx.compose.ui:ui-android:1.7.0-beta01 +androidx.compose.ui:ui-geometry-android:1.7.0-beta01 +androidx.compose.ui:ui-geometry:1.7.0-beta01 +androidx.compose.ui:ui-graphics-android:1.7.0-beta01 +androidx.compose.ui:ui-graphics:1.7.0-beta01 +androidx.compose.ui:ui-text-android:1.7.0-beta01 +androidx.compose.ui:ui-text:1.7.0-beta01 +androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01 +androidx.compose.ui:ui-tooling-preview:1.7.0-beta01 +androidx.compose.ui:ui-unit-android:1.7.0-beta01 +androidx.compose.ui:ui-unit:1.7.0-beta01 +androidx.compose.ui:ui-util-android:1.7.0-beta01 +androidx.compose.ui:ui-util:1.7.0-beta01 +androidx.compose.ui:ui:1.7.0-beta01 +androidx.compose:compose-bom:2024.02.02 +androidx.concurrent:concurrent-futures:1.1.0 +androidx.core:core-ktx:1.13.1 +androidx.core:core-splashscreen:1.0.1 +androidx.core:core:1.13.1 +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.3.0 +androidx.emoji2:emoji2:1.3.0 +androidx.exifinterface:exifinterface:1.3.7 +androidx.fragment:fragment:1.5.4 +androidx.graphics:graphics-path:1.0.1 +androidx.hilt:hilt-common:1.1.0 +androidx.hilt:hilt-navigation-compose:1.2.0 +androidx.hilt:hilt-navigation:1.2.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.8.1 +androidx.lifecycle:lifecycle-common-jvm:2.8.1 +androidx.lifecycle:lifecycle-common:2.8.1 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.1 +androidx.lifecycle:lifecycle-livedata-core:2.8.1 +androidx.lifecycle:lifecycle-livedata:2.8.1 +androidx.lifecycle:lifecycle-process:2.8.1 +androidx.lifecycle:lifecycle-runtime-android:2.8.1 +androidx.lifecycle:lifecycle-runtime-compose-android:2.8.1 +androidx.lifecycle:lifecycle-runtime-compose:2.8.1 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.1 +androidx.lifecycle:lifecycle-runtime-ktx:2.8.1 +androidx.lifecycle:lifecycle-runtime:2.8.1 +androidx.lifecycle:lifecycle-service:2.8.1 +androidx.lifecycle:lifecycle-viewmodel-android:2.8.1 +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.8.1 +androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.1 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.1 +androidx.lifecycle:lifecycle-viewmodel:2.8.1 +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.8.0-alpha06 +androidx.navigation:navigation-common:2.8.0-alpha06 +androidx.navigation:navigation-compose:2.8.0-alpha06 +androidx.navigation:navigation-runtime-ktx:2.8.0-alpha06 +androidx.navigation:navigation-runtime:2.8.0-alpha06 +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.3.0-alpha02 +androidx.tracing:tracing-perfetto:1.0.0 +androidx.tracing:tracing:1.3.0-alpha02 +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.extensions.core:core:1.0.0 +androidx.window:window-core-android:1.3.0-beta02 +androidx.window:window-core:1.3.0-beta02 +androidx.window:window:1.3.0-beta02 +androidx.work:work-runtime-ktx:2.9.0 +androidx.work:work-runtime:2.9.0 +com.caverock:androidsvg-aar:1.4 +com.google.accompanist:accompanist-drawablepainter:0.32.0 +com.google.accompanist:accompanist-permissions:0.34.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.51.1 +com.google.dagger:dagger:2.51.1 +com.google.dagger:hilt-android:2.51.1 +com.google.dagger:hilt-core:2.51.1 +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:4.26.1 +com.google.protobuf:protobuf-kotlin-lite:4.26.1 +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.8.0 +com.squareup.okio:okio:3.8.0 +com.squareup.retrofit2:retrofit:2.9.0 +io.coil-kt:coil-base:2.6.0 +io.coil-kt:coil-compose-base:2.6.0 +io.coil-kt:coil-compose:2.6.0 +io.coil-kt:coil-svg:2.6.0 +io.coil-kt:coil:2.6.0 +javax.inject:javax.inject:1 +org.checkerframework:checker-qual:3.12.0 +org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 +org.jetbrains.kotlin:kotlin-stdlib:2.0.0 +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0 +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0 +org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0 +org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.8.0 +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.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3 +org.jetbrains:annotations:23.0.0 diff --git a/app/prodRelease-badging.txt b/app/prodRelease-badging.txt index e02e43563..4cd4bceab 100644 --- a/app/prodRelease-badging.txt +++ b/app/prodRelease-badging.txt @@ -6,9 +6,9 @@ uses-permission: name='android.permission.ACCESS_NETWORK_STATE' uses-permission: name='android.permission.POST_NOTIFICATIONS' uses-permission: name='android.permission.WAKE_LOCK' uses-permission: name='com.google.android.c2dm.permission.RECEIVE' -uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE' uses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED' uses-permission: name='android.permission.FOREGROUND_SERVICE' +uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE' uses-permission: name='com.google.samples.apps.nowinandroid.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION' application-label:'Now in Android' application-label-af:'Now in Android' @@ -120,3 +120,4 @@ supports-screens: 'small' 'normal' 'large' 'xlarge' supports-any-density: 'true' locales: '--_--' 'af' 'am' 'ar' 'as' 'az' 'be' 'bg' 'bn' 'bs' 'ca' 'cs' 'da' 'de' 'el' 'en-AU' 'en-CA' 'en-GB' 'en-IN' 'en-XC' 'es' 'es-US' 'et' 'eu' 'fa' 'fi' 'fr' 'fr-CA' 'gl' 'gu' 'hi' 'hr' 'hu' 'hy' 'in' 'is' 'it' 'iw' 'ja' 'ka' 'kk' 'km' 'kn' 'ko' 'ky' 'lo' 'lt' 'lv' 'mk' 'ml' 'mn' 'mr' 'ms' 'my' 'nb' 'ne' 'nl' 'or' 'pa' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'si' 'sk' 'sl' 'sq' 'sr' 'sr-Latn' 'sv' 'sw' 'ta' 'te' 'th' 'tl' 'tr' 'uk' 'ur' 'uz' 'vi' 'zh-CN' 'zh-HK' 'zh-TW' 'zu' densities: '120' '160' '240' '320' '480' '640' '65534' +native-code: 'arm64-v8a' 'armeabi-v7a' 'x86' 'x86_64' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9c7f3b935..5f4922bce 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,13 +1,3 @@ --dontwarn org.bouncycastle.jsse.BCSSLParameters --dontwarn org.bouncycastle.jsse.BCSSLSocket --dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider --dontwarn org.conscrypt.Conscrypt$Version --dontwarn org.conscrypt.Conscrypt --dontwarn org.conscrypt.ConscryptHostnameVerifier --dontwarn org.openjsse.javax.net.ssl.SSLParameters --dontwarn org.openjsse.javax.net.ssl.SSLSocket --dontwarn org.openjsse.net.ssl.OpenJSSE - # Fix for Retrofit issue https://github.com/square/retrofit/issues/3751 # Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). -keep,allowobfuscation,allowshrinking interface retrofit2.Call @@ -16,4 +6,4 @@ # With R8 full mode generic signatures are stripped for classes that are not # kept. Suspend functions are wrapped in continuations where the type argument # is used. --keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation \ No newline at end of file +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index e1eab4796..93c674bcc 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -20,7 +20,6 @@ import androidx.annotation.StringRes import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule @@ -42,7 +41,7 @@ import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule import org.junit.Test @@ -51,7 +50,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 /** @@ -90,18 +89,18 @@ class NavigationTest { lateinit var topicsRepository: TopicsRepository private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = - ReadOnlyProperty { _, _ -> activity.getString(resId) } + ReadOnlyProperty { _, _ -> 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 +165,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() } @@ -222,12 +224,7 @@ class NavigationTest { onNodeWithText(ok).performClick() // Check that the saved screen is still visible and selected. - onNode( - hasText(saved) and - hasAnyAncestor( - hasTestTag("NiaBottomBar") or hasTestTag("NiaNavRail"), - ), - ).assertIsSelected() + onNode(hasText(saved) and hasTestTag("NiaNavItem")).assertIsSelected() } } @@ -265,14 +262,16 @@ class NavigationTest { } @Test - fun navigationBar_multipleBackStackInterests() = runTest { + fun navigationBar_multipleBackStackInterests() { composeTestRule.apply { onNodeWithText(interests).performClick() // Select the last topic - val topic = topicsRepository.getTopics().first().sortedBy(Topic::name).last().name - onNodeWithTag("interests:topics").performScrollToNode(hasText(topic)) - onNodeWithText(topic).performClick() + val topic = runBlocking { + topicsRepository.getTopics().first().sortedBy(Topic::name).last() + } + onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name)) + onNodeWithText(topic.name).performClick() // Switch tab onNodeWithText(forYou).performClick() @@ -280,8 +279,8 @@ class NavigationTest { // Come back to Interests onNodeWithText(interests).performClick() - // Verify we're not in the list of interests - onNodeWithTag("interests:topics").assertDoesNotExist() + // Verify the topic is still shown + onNodeWithTag("topic:${topic.id}").assertExists() } } } diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt deleted file mode 100644 index d92390918..000000000 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.ui - -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import com.google.accompanist.testharness.TestHarness -import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository -import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor -import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule -import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository -import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository -import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity -import dagger.hilt.android.testing.BindValue -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import javax.inject.Inject - -/** - * Tests that the navigation UI is rendered correctly on different screen sizes. - */ -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) -@HiltAndroidTest -class NavigationUiTest { - - /** - * Manages the components' state and is used to perform injection on your test - */ - @get:Rule(order = 0) - val hiltRule = HiltAndroidRule(this) - - /** - * Create a temporary folder used to create a Data Store file. This guarantees that - * the file is removed in between each test, preventing a crash. - */ - @BindValue - @get:Rule(order = 1) - val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() - - /** - * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission. - */ - @get:Rule(order = 2) - val postNotificationsPermission = GrantPostNotificationsPermissionRule() - - /** - * Use a test activity to set the content on. - */ - @get:Rule(order = 3) - val composeTestRule = createAndroidComposeRule() - - val userNewsResourceRepository = CompositeUserNewsResourceRepository( - newsRepository = TestNewsRepository(), - userDataRepository = TestUserDataRepository(), - ) - - @Inject - lateinit var networkMonitor: NetworkMonitor - - @Before - fun setup() { - hiltRule.inject() - } - - @Test - fun compactWidth_compactHeight_showsNavigationBar() { - composeTestRule.setContent { - TestHarness(size = DpSize(400.dp, 400.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() - } - - @Test - fun mediumWidth_compactHeight_showsNavigationRail() { - composeTestRule.setContent { - TestHarness(size = DpSize(610.dp, 400.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() - } - - @Test - fun expandedWidth_compactHeight_showsNavigationRail() { - composeTestRule.setContent { - TestHarness(size = DpSize(900.dp, 400.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() - } - - @Test - fun compactWidth_mediumHeight_showsNavigationBar() { - composeTestRule.setContent { - TestHarness(size = DpSize(400.dp, 500.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() - } - - @Test - fun mediumWidth_mediumHeight_showsNavigationRail() { - composeTestRule.setContent { - TestHarness(size = DpSize(610.dp, 500.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() - } - - @Test - fun expandedWidth_mediumHeight_showsNavigationRail() { - composeTestRule.setContent { - TestHarness(size = DpSize(900.dp, 500.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() - } - - @Test - fun compactWidth_expandedHeight_showsNavigationBar() { - composeTestRule.setContent { - TestHarness(size = DpSize(400.dp, 1000.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() - } - - @Test - fun mediumWidth_expandedHeight_showsNavigationRail() { - composeTestRule.setContent { - TestHarness(size = DpSize(610.dp, 1000.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() - } - - @Test - fun expandedWidth_expandedHeight_showsNavigationRail() { - composeTestRule.setContent { - TestHarness(size = DpSize(900.dp, 1000.dp)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - } - } - - composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() - composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() - } -} diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index 1560a74eb..c2c74458d 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -16,15 +16,11 @@ package com.google.samples.apps.nowinandroid.ui -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.ComposeNavigator import androidx.navigation.compose.composable @@ -34,14 +30,15 @@ import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNe import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor +import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.datetime.TimeZone import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertTrue /** @@ -50,7 +47,6 @@ import kotlin.test.assertTrue * Note: This could become an unit test if Robolectric is added to the project and the Context * is faked. */ -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) class NiaAppStateTest { @get:Rule @@ -59,6 +55,8 @@ class NiaAppStateTest { // Create the test dependencies. private val networkMonitor = TestNetworkMonitor() + private val timeZoneMonitor = TestTimeZoneMonitor() + private val userNewsResourceRepository = CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository()) @@ -75,9 +73,9 @@ class NiaAppStateTest { NiaAppState( navController = navController, coroutineScope = backgroundScope, - windowSizeClass = getCompactWindowClass(), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, ) } @@ -97,9 +95,9 @@ class NiaAppStateTest { fun niaAppState_destinations() = runTest { composeTestRule.setContent { state = rememberNiaAppState( - windowSizeClass = getCompactWindowClass(), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, ) } @@ -110,80 +108,50 @@ class NiaAppStateTest { } @Test - fun niaAppState_showBottomBar_compact() = runTest { + fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) { composeTestRule.setContent { state = NiaAppState( navController = NavHostController(LocalContext.current), coroutineScope = backgroundScope, - windowSizeClass = getCompactWindowClass(), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, ) } - assertTrue(state.shouldShowBottomBar) - assertFalse(state.shouldShowNavRail) - } - - @Test - fun niaAppState_showNavRail_medium() = runTest { - composeTestRule.setContent { - state = NiaAppState( - navController = NavHostController(LocalContext.current), - coroutineScope = backgroundScope, - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - - assertTrue(state.shouldShowNavRail) - assertFalse(state.shouldShowBottomBar) - } - - @Test - fun niaAppState_showNavRail_large() = runTest { - composeTestRule.setContent { - state = NiaAppState( - navController = NavHostController(LocalContext.current), - coroutineScope = backgroundScope, - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) - } - - assertTrue(state.shouldShowNavRail) - assertFalse(state.shouldShowBottomBar) + backgroundScope.launch { state.isOffline.collect() } + networkMonitor.setConnected(false) + assertEquals( + true, + state.isOffline.value, + ) } @Test - fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { + fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) { composeTestRule.setContent { state = NiaAppState( navController = NavHostController(LocalContext.current), coroutineScope = backgroundScope, - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, ) } - - backgroundScope.launch { state.isOffline.collect() } - networkMonitor.setConnected(false) + val changedTz = TimeZone.of("Europe/Prague") + backgroundScope.launch { state.currentTimeZone.collect() } + timeZoneMonitor.setTimeZone(changedTz) assertEquals( - true, - state.isOffline.value, + changedTz, + state.currentTimeZone.value, ) } - - private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp)) } @Composable private fun rememberTestNavController(): TestNavHostController { val context = LocalContext.current - return remember { + return remember { TestNavHostController(context).apply { navigatorProvider.addNavigator(ComposeNavigator()) graph = createGraph(startDestination = "a") { diff --git a/app/src/benchmark/res/values-night/colors.xml b/app/src/benchmark/res/values-night/colors.xml index 677eb4e03..cbf22c766 100644 --- a/app/src/benchmark/res/values-night/colors.xml +++ b/app/src/benchmark/res/values-night/colors.xml @@ -15,6 +15,8 @@ limitations under the License. --> + #FFFFFF #FF006780 diff --git a/app/src/benchmark/res/values/colors.xml b/app/src/benchmark/res/values/colors.xml index d33b7ba72..a98c6d8f6 100644 --- a/app/src/benchmark/res/values/colors.xml +++ b/app/src/benchmark/res/values/colors.xml @@ -15,6 +15,8 @@ limitations under the License. --> + #000000 #FF006780 diff --git a/app/src/debug/res/values-night/colors.xml b/app/src/debug/res/values-night/colors.xml index d6a4c98e0..daa017e4a 100644 --- a/app/src/debug/res/values-night/colors.xml +++ b/app/src/debug/res/values-night/colors.xml @@ -15,6 +15,8 @@ limitations under the License. --> + #FFFFFF #FFA23F16 diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml index 6365ddb3f..487a7820b 100644 --- a/app/src/debug/res/values/colors.xml +++ b/app/src/debug/res/values/colors.xml @@ -15,6 +15,8 @@ limitations under the License. --> + #000000 #FFA23F16 diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt index 7fe1bc674..2f8572102 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -17,15 +17,13 @@ package com.google.samples.apps.nowinandroid import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -34,32 +32,31 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.metrics.performance.JankStats -import androidx.profileinstaller.ProfileVerifier import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper 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.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand +import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone import com.google.samples.apps.nowinandroid.ui.NiaApp +import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import javax.inject.Inject private const val TAG = "MainActivity" -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -72,6 +69,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + @Inject lateinit var analyticsHelper: AnalyticsHelper @@ -90,9 +90,7 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState - .onEach { - uiState = it - } + .onEach { uiState = it } .collect() } } @@ -133,17 +131,25 @@ class MainActivity : ComponentActivity() { onDispose {} } - CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) { + val appState = rememberNiaAppState( + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, + ) + + val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle() + + CompositionLocalProvider( + LocalAnalyticsHelper provides analyticsHelper, + LocalTimeZone provides currentTimeZone, + ) { NiaTheme( darkTheme = darkTheme, androidTheme = shouldUseAndroidTheme(uiState), disableDynamicTheming = shouldDisableDynamicTheming(uiState), ) { - NiaApp( - networkMonitor = networkMonitor, - windowSizeClass = calculateWindowSizeClass(this), - userNewsResourceRepository = userNewsResourceRepository, - ) + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + NiaApp(appState) } } } @@ -152,48 +158,12 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() lazyStats.get().isTrackingEnabled = true - lifecycleScope.launch { - logCompilationStatus() - } } override fun onPause() { super.onPause() lazyStats.get().isTrackingEnabled = false } - - /** - * Logs the app's Baseline Profile Compilation Status using [ProfileVerifier]. - */ - private suspend fun logCompilationStatus() { - /* - When delivering through Google Play, the baseline profile is compiled during installation. - In this case you will see the correct state logged without any further action necessary. - To verify baseline profile installation locally, you need to manually trigger baseline - profile installation. - For immediate compilation, call: - `adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target` - You can also trigger background optimizations: - `adb shell pm bg-dexopt-job` - Both jobs run asynchronously and might take some time complete. - 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}") - Log.d( - TAG, - when { - status.isCompiledWithProfile -> "ProfileInstaller: is compiled with profile" - status.hasProfileEnqueuedForCompilation() -> - "ProfileInstaller: Enqueued for compilation" - else -> "Profile not compiled or enqueued" - }, - ) - } - } } /** diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt index 699f52575..8e3ad814a 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt @@ -20,9 +20,9 @@ import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory import com.google.samples.apps.nowinandroid.sync.initializers.Sync +import com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject -import javax.inject.Provider /** * [Application] class for NiA @@ -30,12 +30,16 @@ import javax.inject.Provider @HiltAndroidApp class NiaApplication : Application(), ImageLoaderFactory { @Inject - lateinit var imageLoader: Provider + lateinit var imageLoader: dagger.Lazy + + @Inject + lateinit var profileVerifierLogger: ProfileVerifierLogger override fun onCreate() { super.onCreate() // Initialize Sync; the system responsible for keeping data in the app up to date. Sync.initialize(context = this) + profileVerifierLogger() } override fun newImageLoader(): ImageLoader = imageLoader.get() diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt index be64d057f..56d1b6e24 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt @@ -20,6 +20,7 @@ import android.app.Activity import android.util.Log import android.view.Window import androidx.metrics.performance.JankStats +import androidx.metrics.performance.JankStats.OnFrameListener import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -29,26 +30,20 @@ import dagger.hilt.android.components.ActivityComponent @InstallIn(ActivityComponent::class) object JankStatsModule { @Provides - fun providesOnFrameListener(): JankStats.OnFrameListener { - return JankStats.OnFrameListener { frameData -> - // Make sure to only log janky frames. - if (frameData.isJank) { - // We're currently logging this but would better report it to a backend. - Log.v("NiA Jank", frameData.toString()) - } + fun providesOnFrameListener(): OnFrameListener = OnFrameListener { frameData -> + // Make sure to only log janky frames. + if (frameData.isJank) { + // We're currently logging this but would better report it to a backend. + Log.v("NiA Jank", frameData.toString()) } } @Provides - fun providesWindow(activity: Activity): Window { - return activity.window - } + fun providesWindow(activity: Activity): Window = activity.window @Provides fun providesJankStats( window: Window, - frameListener: JankStats.OnFrameListener, - ): JankStats { - return JankStats.createAndTrack(window, frameListener) - } + frameListener: OnFrameListener, + ): JankStats = JankStats.createAndTrack(window, frameListener) } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 3d58ed5a6..39bc03de7 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -20,14 +20,13 @@ 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.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen -import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic -import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.ui.NiaAppState +import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen /** * Top-level navigation graph. Navigation is organized as explained at @@ -41,7 +40,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( @@ -49,24 +48,16 @@ fun NiaNavHost( startDestination = startDestination, modifier = modifier, ) { - forYouScreen(onTopicClick = navController::navigateToTopic) + forYouScreen(onTopicClick = navController::navigateToInterests) bookmarksScreen( - onTopicClick = navController::navigateToTopic, + onTopicClick = navController::navigateToInterests, onShowSnackbar = onShowSnackbar, ) searchScreen( onBackClick = navController::popBackStack, onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, - onTopicClick = navController::navigateToTopic, - ) - interestsGraph( - onTopicClick = navController::navigateToTopic, - nestedGraphs = { - topicScreen( - onBackClick = navController::popBackStack, - onTopicClick = navController::navigateToTopic, - ) - }, + onTopicClick = navController::navigateToInterests, ) + interestsListDetailScreen() } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt index 8dbd0fcb6..aca7d54ab 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -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, ), } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index aa85afebd..b47984ddb 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -16,9 +16,8 @@ package com.google.samples.apps.nowinandroid.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -26,7 +25,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -39,7 +37,9 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult.ActionPerformed import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -62,14 +62,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import com.google.samples.apps.nowinandroid.R -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.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground -import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar -import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem -import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail -import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem +import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationSuiteScaffold import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors @@ -79,29 +74,18 @@ import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalLayoutApi::class, - ExperimentalComposeUiApi::class, -) +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun NiaApp( - windowSizeClass: WindowSizeClass, - networkMonitor: NetworkMonitor, - userNewsResourceRepository: UserNewsResourceRepository, - appState: NiaAppState = rememberNiaAppState( - networkMonitor = networkMonitor, - windowSizeClass = windowSizeClass, - userNewsResourceRepository = userNewsResourceRepository, - ), + appState: NiaAppState, + modifier: Modifier = Modifier, + windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), ) { val shouldShowGradientBackground = appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU - var showSettingsDialog by rememberSaveable { - mutableStateOf(false) - } + var showSettingsDialog by rememberSaveable { mutableStateOf(false) } - NiaBackground { + NiaBackground(modifier = modifier) { NiaGradientBackground( gradientColors = if (shouldShowGradientBackground) { LocalGradientColors.current @@ -124,162 +108,141 @@ fun NiaApp( } } - if (showSettingsDialog) { - SettingsDialog( - onDismiss = { showSettingsDialog = false }, - ) - } + NiaApp( + appState = appState, + snackbarHostState = snackbarHostState, + showSettingsDialog = showSettingsDialog, + onSettingsDismissed = { showSettingsDialog = false }, + onTopAppBarActionClick = { showSettingsDialog = true }, + windowAdaptiveInfo = windowAdaptiveInfo, + ) + } + } +} + +@Composable +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalComposeUiApi::class, + ExperimentalMaterial3AdaptiveApi::class, +) +internal fun NiaApp( + appState: NiaAppState, + snackbarHostState: SnackbarHostState, + showSettingsDialog: Boolean, + onSettingsDismissed: () -> Unit, + onTopAppBarActionClick: () -> Unit, + modifier: Modifier = Modifier, + windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), +) { + val unreadDestinations by appState.topLevelDestinationsWithUnreadResources + .collectAsStateWithLifecycle() + val currentDestination = appState.currentDestination - val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() + if (showSettingsDialog) { + SettingsDialog( + onDismiss = { onSettingsDismissed() }, + ) + } - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - contentWindowInsets = WindowInsets(0, 0, 0, 0), - snackbarHost = { SnackbarHost(snackbarHostState) }, - bottomBar = { - if (appState.shouldShowBottomBar) { - NiaBottomBar( - destinations = appState.topLevelDestinations, - destinationsWithUnreadResources = unreadDestinations, - onNavigateToDestination = appState::navigateToTopLevelDestination, - currentDestination = appState.currentDestination, - modifier = Modifier.testTag("NiaBottomBar"), + NiaNavigationSuiteScaffold( + navigationSuiteItems = { + appState.topLevelDestinations.forEach { destination -> + val hasUnread = unreadDestinations.contains(destination) + val selected = currentDestination + .isTopLevelDestinationInHierarchy(destination) + item( + selected = selected, + onClick = { appState.navigateToTopLevelDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = null, ) - } - }, - ) { padding -> - Row( + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = null, + ) + }, + label = { Text(stringResource(destination.iconTextId)) }, + modifier = Modifier - .fillMaxSize() - .padding(padding) - .consumeWindowInsets(padding) - .windowInsetsPadding( - WindowInsets.safeDrawing.only( - WindowInsetsSides.Horizontal, - ), + .testTag("NiaNavItem") + .then(if (hasUnread) Modifier.notificationDot() else Modifier), + ) + } + }, + windowAdaptiveInfo = windowAdaptiveInfo, + ) { + Scaffold( + modifier = modifier.semantics { + testTagsAsResourceId = true + }, + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, ), - ) { - if (appState.shouldShowNavRail) { - NiaNavRail( - destinations = appState.topLevelDestinations, - destinationsWithUnreadResources = unreadDestinations, - onNavigateToDestination = appState::navigateToTopLevelDestination, - currentDestination = appState.currentDestination, - modifier = Modifier - .testTag("NiaNavRail") - .safeDrawingPadding(), - ) - } - - Column(Modifier.fillMaxSize()) { - // Show the top app bar on top level destinations. - val destination = appState.currentTopLevelDestination - if (destination != null) { - NiaTopAppBar( - titleRes = destination.titleTextId, - navigationIcon = NiaIcons.Search, - navigationIconContentDescription = stringResource( - id = settingsR.string.top_app_bar_navigation_icon_description, - ), - actionIcon = NiaIcons.Settings, - actionIconContentDescription = stringResource( - id = settingsR.string.top_app_bar_action_icon_description, - ), - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent, - ), - onActionClick = { showSettingsDialog = true }, - onNavigationClick = { appState.navigateToSearch() }, - ) - } + ), + ) { + // Show the top app bar on top level destinations. + val destination = appState.currentTopLevelDestination + val shouldShowTopAppBar = destination != null + if (destination != null) { + NiaTopAppBar( + titleRes = destination.titleTextId, + navigationIcon = NiaIcons.Search, + navigationIconContentDescription = stringResource( + id = settingsR.string.feature_settings_top_app_bar_navigation_icon_description, + ), + actionIcon = NiaIcons.Settings, + actionIconContentDescription = stringResource( + id = settingsR.string.feature_settings_top_app_bar_action_icon_description, + ), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + ), + onActionClick = { onTopAppBarActionClick() }, + onNavigationClick = { appState.navigateToSearch() }, + ) + } - NiaNavHost(appState = appState, onShowSnackbar = { message, action -> + Box( + // Workaround for https://issuetracker.google.com/338478720 + modifier = Modifier.consumeWindowInsets( + if (shouldShowTopAppBar) { + WindowInsets.safeDrawing.only(WindowInsetsSides.Top) + } else { + WindowInsets(0, 0, 0, 0) + }, + ), + ) { + NiaNavHost( + appState = appState, + onShowSnackbar = { message, action -> snackbarHostState.showSnackbar( message = message, actionLabel = action, duration = Short, ) == ActionPerformed - }) - } - - // TODO: We may want to add padding or spacer when the snackbar is shown so that - // content doesn't display behind it. - } - } - } - } -} - -@Composable -private fun NiaNavRail( - destinations: List, - destinationsWithUnreadResources: Set, - onNavigateToDestination: (TopLevelDestination) -> Unit, - currentDestination: NavDestination?, - modifier: Modifier = Modifier, -) { - NiaNavigationRail(modifier = modifier) { - destinations.forEach { destination -> - val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) - val hasUnread = destinationsWithUnreadResources.contains(destination) - NiaNavigationRailItem( - selected = selected, - onClick = { onNavigateToDestination(destination) }, - icon = { - Icon( - imageVector = destination.unselectedIcon, - contentDescription = null, + }, ) - }, - selectedIcon = { - Icon( - imageVector = destination.selectedIcon, - contentDescription = null, - ) - }, - label = { Text(stringResource(destination.iconTextId)) }, - modifier = if (hasUnread) Modifier.notificationDot() else Modifier, - ) - } - } -} + } -@Composable -private fun NiaBottomBar( - destinations: List, - destinationsWithUnreadResources: Set, - onNavigateToDestination: (TopLevelDestination) -> Unit, - currentDestination: NavDestination?, - modifier: Modifier = Modifier, -) { - NiaNavigationBar( - modifier = modifier, - ) { - destinations.forEach { destination -> - val hasUnread = destinationsWithUnreadResources.contains(destination) - val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) - NiaNavigationBarItem( - selected = selected, - onClick = { onNavigateToDestination(destination) }, - icon = { - Icon( - imageVector = destination.unselectedIcon, - contentDescription = null, - ) - }, - selectedIcon = { - Icon( - imageVector = destination.selectedIcon, - contentDescription = null, - ) - }, - label = { Text(stringResource(destination.iconTextId)) }, - modifier = if (hasUnread) Modifier.notificationDot() else Modifier, - ) + // TODO: We may want to add padding or spacer when the snackbar is shown so that + // content doesn't display behind it. + } } } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 09e70069e..519603579 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -16,8 +16,6 @@ package com.google.samples.apps.nowinandroid.ui -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember @@ -32,13 +30,14 @@ import androidx.navigation.navOptions 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.data.util.TimeZoneMonitor 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.navigateToInterestsGraph +import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE +import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS @@ -50,12 +49,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.datetime.TimeZone @Composable fun rememberNiaAppState( - windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, + timeZoneMonitor: TimeZoneMonitor, coroutineScope: CoroutineScope = rememberCoroutineScope(), navController: NavHostController = rememberNavController(), ): NiaAppState { @@ -63,16 +63,16 @@ fun rememberNiaAppState( return remember( navController, coroutineScope, - windowSizeClass, networkMonitor, userNewsResourceRepository, + timeZoneMonitor, ) { NiaAppState( - navController, - coroutineScope, - windowSizeClass, - networkMonitor, - userNewsResourceRepository, + navController = navController, + coroutineScope = coroutineScope, + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, ) } } @@ -80,10 +80,10 @@ fun rememberNiaAppState( @Stable class NiaAppState( val navController: NavHostController, - val coroutineScope: CoroutineScope, - val windowSizeClass: WindowSizeClass, + coroutineScope: CoroutineScope, networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, + timeZoneMonitor: TimeZoneMonitor, ) { val currentDestination: NavDestination? @Composable get() = navController @@ -91,18 +91,12 @@ 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 } - val shouldShowBottomBar: Boolean - get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact - - val shouldShowNavRail: Boolean - get() = !shouldShowBottomBar - val isOffline = networkMonitor.isOnline .map(Boolean::not) .stateIn( @@ -115,7 +109,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.values().asList() + val topLevelDestinations: List = TopLevelDestination.entries /** * The top level destinations that have unread news resources. @@ -127,12 +121,20 @@ class NiaAppState( FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } }, BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } }, ) - }.stateIn( + } + .stateIn( coroutineScope, SharingStarted.WhileSubscribed(5_000), initialValue = emptySet(), ) + val currentTimeZone = timeZoneMonitor.currentTimeZone + .stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(5_000), + TimeZone.currentSystemDefault(), + ) + /** * UI logic for navigating to a top level destination in the app. Top level destinations have * only one copy of the destination of the back stack, and save and restore state whenever you @@ -159,14 +161,12 @@ class NiaAppState( when (topLevelDestination) { FOR_YOU -> navController.navigateToForYou(topLevelNavOptions) BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions) - INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions) + INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions) } } } - fun navigateToSearch() { - navController.navigateToSearch() - } + fun navigateToSearch() = navController.navigateToSearch() } /** diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt new file mode 100644 index 000000000..40ce9c116 --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui.interests2pane + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +class Interests2PaneViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + val selectedTopicId: StateFlow = + savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG]) + + fun onTopicClick(topicId: String?) { + savedStateHandle[TOPIC_ID_ARG] = topicId + } +} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt new file mode 100644 index 000000000..ada4e49d1 --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui.interests2pane + +import androidx.activity.compose.BackHandler +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG +import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder +import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE +import com.google.samples.apps.nowinandroid.feature.topic.navigation.createTopicRoute +import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic +import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen +import java.util.UUID + +private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route" + +fun NavGraphBuilder.interestsListDetailScreen() { + composable( + route = INTERESTS_ROUTE, + arguments = listOf( + navArgument(TOPIC_ID_ARG) { + type = NavType.StringType + defaultValue = null + nullable = true + }, + ), + ) { + InterestsListDetailScreen() + } +} + +@Composable +internal fun InterestsListDetailScreen( + viewModel: Interests2PaneViewModel = hiltViewModel(), +) { + val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle() + InterestsListDetailScreen( + selectedTopicId = selectedTopicId, + onTopicClick = viewModel::onTopicClick, + ) +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +internal fun InterestsListDetailScreen( + selectedTopicId: String?, + onTopicClick: (String) -> Unit, +) { + val listDetailNavigator = rememberListDetailPaneScaffoldNavigator( + initialDestinationHistory = listOfNotNull( + ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), + ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail).takeIf { + selectedTopicId != null + }, + ), + ) + BackHandler(listDetailNavigator.canNavigateBack()) { + listDetailNavigator.navigateBack() + } + + var nestedNavHostStartDestination by remember { + mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE) + } + var nestedNavKey by rememberSaveable( + stateSaver = Saver({ it.toString() }, UUID::fromString), + ) { + mutableStateOf(UUID.randomUUID()) + } + val nestedNavController = key(nestedNavKey) { + rememberNavController() + } + + fun onTopicClickShowDetailPane(topicId: String) { + onTopicClick(topicId) + if (listDetailNavigator.isDetailPaneVisible()) { + // If the detail pane was visible, then use the nestedNavController navigate call + // directly + nestedNavController.navigateToTopic(topicId) { + popUpTo(DETAIL_PANE_NAVHOST_ROUTE) + } + } else { + // Otherwise, recreate the NavHost entirely, and start at the new destination + nestedNavHostStartDestination = createTopicRoute(topicId) + nestedNavKey = UUID.randomUUID() + } + listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + } + + ListDetailPaneScaffold( + value = listDetailNavigator.scaffoldValue, + directive = listDetailNavigator.scaffoldDirective, + listPane = { + AnimatedPane { + InterestsRoute( + onTopicClick = ::onTopicClickShowDetailPane, + highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(), + ) + } + }, + detailPane = { + AnimatedPane { + key(nestedNavKey) { + NavHost( + navController = nestedNavController, + startDestination = nestedNavHostStartDestination, + route = DETAIL_PANE_NAVHOST_ROUTE, + ) { + topicScreen( + showBackButton = !listDetailNavigator.isListPaneVisible(), + onBackClick = listDetailNavigator::navigateBack, + onTopicClick = ::onTopicClickShowDetailPane, + ) + composable(route = TOPIC_ROUTE) { + TopicDetailPlaceholder() + } + } + } + } + }, + ) +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun ThreePaneScaffoldNavigator.isListPaneVisible(): Boolean = + scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun ThreePaneScaffoldNavigator.isDetailPaneVisible(): Boolean = + scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt new file mode 100644 index 000000000..595166f03 --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.util + +import android.util.Log +import androidx.profileinstaller.ProfileVerifier +import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Logs the app's Baseline Profile Compilation Status using [ProfileVerifier]. + * + * When delivering through Google Play, the baseline profile is compiled during installation. + * In this case you will see the correct state logged without any further action necessary. + * To verify baseline profile installation locally, you need to manually trigger baseline + * profile installation. + * + * For immediate compilation, call: + * ```bash + * adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target + * ``` + * You can also trigger background optimizations: + * ```bash + * adb shell pm bg-dexopt-job + * ``` + * Both jobs run asynchronously and might take some time complete. + * + * 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. + * + * @see androidx.profileinstaller.ProfileVerifier.CompilationStatus.ResultCode + */ +class ProfileVerifierLogger @Inject constructor( + @ApplicationScope private val scope: CoroutineScope, +) { + companion object { + private const val TAG = "ProfileInstaller" + } + + operator fun invoke() = scope.launch { + val status = ProfileVerifier.getCompilationStatusAsync().await() + Log.d(TAG, "Status code: ${status.profileInstallResultCode}") + Log.d( + TAG, + when { + status.isCompiledWithProfile -> "App compiled with profile" + status.hasProfileEnqueuedForCompilation() -> "Profile enqueued for compilation" + else -> "Profile not compiled nor enqueued" + }, + ) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 6d5711aa5..77280bad5 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -20,10 +20,10 @@ android:viewportWidth="108" android:viewportHeight="108"> diff --git a/app/src/main/res/drawable/ic_splash.xml b/app/src/main/res/drawable/ic_splash.xml index 6de9c8c9b..144393be9 100644 --- a/app/src/main/res/drawable/ic_splash.xml +++ b/app/src/main/res/drawable/ic_splash.xml @@ -24,11 +24,11 @@ android:pathData="M0,0h108v108h-108z" android:fillColor="@color/ic_launcher_background_tint"/> - \ No newline at end of file + diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt new file mode 100644 index 000000000..2fc88e561 --- /dev/null +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui + +import android.view.WindowInsets +import android.widget.FrameLayout +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.children + +/** + * A [DeviceConfigurationOverride] that allows overriding the [windowInsets] available + * to the content under test. + */ +@Suppress("ktlint:standard:function-naming") +fun DeviceConfigurationOverride.Companion.WindowInsets( + windowInsets: WindowInsetsCompat, +): DeviceConfigurationOverride = DeviceConfigurationOverride { contentUnderTest -> + val currentContentUnderTest by rememberUpdatedState(contentUnderTest) + val currentWindowInsets by rememberUpdatedState(windowInsets) + AndroidView( + factory = { context -> + object : FrameLayout(context) { + override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { + children.forEach { + it.dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()) + } + return WindowInsetsCompat.CONSUMED.toWindowInsets()!! + } + + /** + * Deprecated, but intercept the `requestApplyInsets` call via the deprecated + * method. + */ + @Deprecated("Deprecated in Java") + override fun requestFitSystemWindows() { + dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()!!) + } + }.apply { + addView( + ComposeView(context).apply { + setContent { + currentContentUnderTest() + } + }, + ) + } + }, + ) +} diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt index bac088482..1cca5a13a 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -16,27 +16,26 @@ package com.google.samples.apps.nowinandroid.ui -import android.util.Log -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.Posture +import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.ForcedSize import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.test.platform.app.InstrumentationRegistry -import androidx.work.Configuration -import androidx.work.testing.SynchronousExecutor -import androidx.work.testing.WorkManagerTestInitHelper +import androidx.window.core.layout.WindowSizeClass import com.github.takahirom.roborazzi.captureRoboImage -import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import dagger.hilt.android.testing.BindValue @@ -60,12 +59,11 @@ import javax.inject.Inject /** * Tests that the navigation UI is rendered correctly on different screen sizes. */ -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @RunWith(RobolectricTestRunner::class) @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 { @@ -93,6 +91,9 @@ class NiaAppScreenSizesScreenshotTests { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + @Inject lateinit var userDataRepository: UserDataRepository @@ -104,17 +105,6 @@ class NiaAppScreenSizesScreenshotTests { @Before fun setup() { - val config = Configuration.Builder() - .setMinimumLoggingLevel(Log.DEBUG) - .setExecutor(SynchronousExecutor()) - .build() - - // Initialize WorkManager for instrumentation tests. - WorkManagerTestInitHelper.initializeTestWorkManager( - InstrumentationRegistry.getInstrumentation().context, - config, - ) - hiltRule.inject() // Configure user data @@ -133,19 +123,30 @@ class NiaAppScreenSizesScreenshotTests { TimeZone.setDefault(TimeZone.getTimeZone("UTC")) } + @OptIn(ExperimentalMaterial3AdaptiveApi::class) private fun testNiaAppScreenshotWithSize(width: Dp, height: Dp, screenshotName: String) { composeTestRule.setContent { CompositionLocalProvider( LocalInspectionMode provides true, ) { - TestHarness(size = DpSize(width, height)) { - BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), + DeviceConfigurationOverride( + override = DeviceConfigurationOverride.ForcedSize(DpSize(width, height)), + ) { + NiaTheme { + val fakeAppState = rememberNiaAppState( networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, + ) + NiaApp( + fakeAppState, + windowAdaptiveInfo = WindowAdaptiveInfo( + windowSizeClass = WindowSizeClass.compute( + width.value, + height.value, + ), + windowPosture = Posture(), + ), ) } } @@ -169,20 +170,20 @@ class NiaAppScreenSizesScreenshotTests { } @Test - fun mediumWidth_compactHeight_showsNavigationRail() { + fun mediumWidth_compactHeight_showsNavigationBar() { testNiaAppScreenshotWithSize( 610.dp, 400.dp, - "mediumWidth_compactHeight_showsNavigationRail", + "mediumWidth_compactHeight_showsNavigationBar", ) } @Test - fun expandedWidth_compactHeight_showsNavigationRail() { + fun expandedWidth_compactHeight_showsNavigationBar() { testNiaAppScreenshotWithSize( 900.dp, 400.dp, - "expandedWidth_compactHeight_showsNavigationRail", + "expandedWidth_compactHeight_showsNavigationBar", ) } diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt new file mode 100644 index 000000000..b9970effd --- /dev/null +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt @@ -0,0 +1,349 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsEndWidth +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.windowInsetsStartWidth +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.material3.SnackbarDuration.Indefinite +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.Posture +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toAndroidRect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.ForcedSize +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import androidx.window.core.layout.WindowSizeClass +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository +import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode +import java.util.TimeZone +import javax.inject.Inject + +/** + * Tests that the Snackbar is correctly displayed on different screen sizes. + */ +@RunWith(RobolectricTestRunner::class) +@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") +@LooperMode(LooperMode.Mode.PAUSED) +@HiltAndroidTest +class SnackbarInsetsScreenshotTests { + + /** + * Manages the components' state and is used to perform injection on your test + */ + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + /** + * Create a temporary folder used to create a Data Store file. This guarantees that + * the file is removed in between each test, preventing a crash. + */ + @BindValue + @get:Rule(order = 1) + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + /** + * Use a test activity to set the content on. + */ + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + + @Inject + lateinit var userDataRepository: FakeUserDataRepository + + @Inject + lateinit var topicsRepository: TopicsRepository + + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + + @Before + fun setup() { + hiltRule.inject() + + // Configure user data + runBlocking { + userDataRepository.setShouldHideOnboarding(true) + + userDataRepository.setFollowedTopicIds( + setOf(topicsRepository.getTopics().first().first().id), + ) + } + } + + @Before + fun setTimeZone() { + // Make time zone deterministic in tests + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @Test + fun phone_noSnackbar() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 400.dp, + 500.dp, + "insets_snackbar_compact_medium_noSnackbar", + action = { }, + ) + } + + @Test + fun snackbarShown_phone() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 400.dp, + 500.dp, + "insets_snackbar_compact_medium", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @Test + fun snackbarShown_foldable() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 600.dp, + 600.dp, + "insets_snackbar_medium_medium", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @Test + fun snackbarShown_tablet() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 900.dp, + 900.dp, + "insets_snackbar_expanded_expanded", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + private fun testSnackbarScreenshotWithSize( + snackbarHostState: SnackbarHostState, + width: Dp, + height: Dp, + screenshotName: String, + action: suspend () -> Unit, + ) { + lateinit var scope: CoroutineScope + composeTestRule.setContent { + CompositionLocalProvider( + // Replaces images with placeholders + LocalInspectionMode provides true, + ) { + scope = rememberCoroutineScope() + + DeviceConfigurationOverride( + DeviceConfigurationOverride.ForcedSize(DpSize(width, height)), + ) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.WindowInsets( + WindowInsetsCompat.Builder() + .setInsets( + WindowInsetsCompat.Type.statusBars(), + DpRect( + left = 0.dp, + top = 64.dp, + right = 0.dp, + bottom = 0.dp, + ).toInsets(), + ) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + DpRect( + left = 64.dp, + top = 0.dp, + right = 64.dp, + bottom = 64.dp, + ).toInsets(), + ) + .build(), + ), + ) { + BoxWithConstraints(Modifier.testTag("root")) { + NiaTheme { + val appState = rememberNiaAppState( + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, + ) + NiaApp( + appState = appState, + snackbarHostState = snackbarHostState, + showSettingsDialog = false, + onSettingsDismissed = {}, + onTopAppBarActionClick = {}, + windowAdaptiveInfo = WindowAdaptiveInfo( + windowSizeClass = WindowSizeClass.compute( + maxWidth.value, + maxHeight.value, + ), + windowPosture = Posture(), + ), + ) + DebugVisibleWindowInsets() + } + } + } + } + } + } + + scope.launch { + action() + } + + composeTestRule.onNodeWithTag("root") + .captureRoboImage( + "src/testDemo/screenshots/$screenshotName.png", + roborazziOptions = DefaultRoborazziOptions, + ) + } +} + +@Composable +fun DebugVisibleWindowInsets( + modifier: Modifier = Modifier, + debugColor: Color = Color.Magenta.copy(alpha = 0.5f), +) { + Box(modifier = modifier.fillMaxSize()) { + Spacer( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxHeight() + .windowInsetsStartWidth(WindowInsets.safeDrawing) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .background(debugColor), + ) + Spacer( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .windowInsetsEndWidth(WindowInsets.safeDrawing) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .background(debugColor), + ) + Spacer( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .windowInsetsTopHeight(WindowInsets.safeDrawing) + .background(debugColor), + ) + Spacer( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.safeDrawing) + .background(debugColor), + ) + } +} + +@Composable +private fun DpRect.toInsets() = toInsets(LocalDensity.current) + +private fun DpRect.toInsets(density: Density) = + Insets.of(with(density) { toRect() }.roundToIntRect().toAndroidRect()) diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt new file mode 100644 index 000000000..6f12dd620 --- /dev/null +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt @@ -0,0 +1,251 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.SnackbarDuration.Indefinite +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.Posture +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.ForcedSize +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowSizeClass +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository +import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode +import java.util.TimeZone +import javax.inject.Inject + +/** + * Tests that the Snackbar is correctly displayed on different screen sizes. + */ +@RunWith(RobolectricTestRunner::class) +@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") +@LooperMode(LooperMode.Mode.PAUSED) +@HiltAndroidTest +class SnackbarScreenshotTests { + + /** + * Manages the components' state and is used to perform injection on your test + */ + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + /** + * Create a temporary folder used to create a Data Store file. This guarantees that + * the file is removed in between each test, preventing a crash. + */ + @BindValue + @get:Rule(order = 1) + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + /** + * Use a test activity to set the content on. + */ + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + + @Inject + lateinit var userDataRepository: FakeUserDataRepository + + @Inject + lateinit var topicsRepository: TopicsRepository + + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + + @Before + fun setup() { + hiltRule.inject() + + // Configure user data + runBlocking { + userDataRepository.setShouldHideOnboarding(true) + + userDataRepository.setFollowedTopicIds( + setOf(topicsRepository.getTopics().first().first().id), + ) + } + } + + @Before + fun setTimeZone() { + // Make time zone deterministic in tests + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @Test + fun phone_noSnackbar() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 400.dp, + 500.dp, + "snackbar_compact_medium_noSnackbar", + action = { }, + ) + } + + @Test + fun snackbarShown_phone() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 400.dp, + 500.dp, + "snackbar_compact_medium", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @Test + fun snackbarShown_foldable() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 600.dp, + 600.dp, + "snackbar_medium_medium", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @Test + fun snackbarShown_tablet() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 900.dp, + 900.dp, + "snackbar_expanded_expanded", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + private fun testSnackbarScreenshotWithSize( + snackbarHostState: SnackbarHostState, + width: Dp, + height: Dp, + screenshotName: String, + action: suspend () -> Unit, + ) { + lateinit var scope: CoroutineScope + composeTestRule.setContent { + CompositionLocalProvider( + // Replaces images with placeholders + LocalInspectionMode provides true, + ) { + scope = rememberCoroutineScope() + + DeviceConfigurationOverride( + DeviceConfigurationOverride.ForcedSize(DpSize(width, height)), + ) { + BoxWithConstraints { + NiaTheme { + val appState = rememberNiaAppState( + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, + ) + NiaApp( + appState = appState, + snackbarHostState = snackbarHostState, + showSettingsDialog = false, + onSettingsDismissed = {}, + onTopAppBarActionClick = {}, + windowAdaptiveInfo = WindowAdaptiveInfo( + windowSizeClass = WindowSizeClass.compute( + maxWidth.value, + maxHeight.value, + ), + windowPosture = Posture(), + ), + ) + } + } + } + } + } + + scope.launch { + action() + } + + composeTestRule.onRoot() + .captureRoboImage( + "src/testDemo/screenshots/$screenshotName.png", + roborazziOptions = DefaultRoborazziOptions, + ) + } +} diff --git a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png index edb9cfa2a..912fca4c7 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png index 035cc24cf..e052b5920 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png index 7749199c5..668d69146 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationBar.png new file mode 100644 index 000000000..1daf5ec34 Binary files /dev/null and b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png deleted file mode 100644 index fe5b045aa..000000000 Binary files a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png and /dev/null differ diff --git a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png index 523b03ec5..53bf6f3c5 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png index 58d620f21..c5b7fe883 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_compact_medium.png b/app/src/testDemo/screenshots/insets_snackbar_compact_medium.png new file mode 100644 index 000000000..aae785a47 Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_compact_medium.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_compact_medium_noSnackbar.png b/app/src/testDemo/screenshots/insets_snackbar_compact_medium_noSnackbar.png new file mode 100644 index 000000000..d37f02c65 Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_compact_medium_noSnackbar.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png b/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png new file mode 100644 index 000000000..3d2c79256 Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_medium_medium.png b/app/src/testDemo/screenshots/insets_snackbar_medium_medium.png new file mode 100644 index 000000000..3e7171bf4 Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_medium_medium.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationBar.png new file mode 100644 index 000000000..4bc5d2b1c Binary files /dev/null and b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png deleted file mode 100644 index 56b49457c..000000000 Binary files a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png and /dev/null differ diff --git a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png index 15ddccf78..3e38938d6 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png index d2e4bb8bc..f914a0454 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/snackbar_compact_medium.png b/app/src/testDemo/screenshots/snackbar_compact_medium.png new file mode 100644 index 000000000..7676de40a Binary files /dev/null and b/app/src/testDemo/screenshots/snackbar_compact_medium.png differ diff --git a/app/src/testDemo/screenshots/snackbar_compact_medium_noSnackbar.png b/app/src/testDemo/screenshots/snackbar_compact_medium_noSnackbar.png new file mode 100644 index 000000000..ff9ed7669 Binary files /dev/null and b/app/src/testDemo/screenshots/snackbar_compact_medium_noSnackbar.png differ diff --git a/app/src/testDemo/screenshots/snackbar_expanded_expanded.png b/app/src/testDemo/screenshots/snackbar_expanded_expanded.png new file mode 100644 index 000000000..4997a83af Binary files /dev/null and b/app/src/testDemo/screenshots/snackbar_expanded_expanded.png differ diff --git a/app/src/testDemo/screenshots/snackbar_medium_medium.png b/app/src/testDemo/screenshots/snackbar_medium_medium.png new file mode 100644 index 000000000..36fffa9c6 Binary files /dev/null and b/app/src/testDemo/screenshots/snackbar_medium_medium.png differ diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 10c0f7acf..279c4b226 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import com.google.samples.apps.nowinandroid.NiaBuildType import com.google.samples.apps.nowinandroid.configureFlavors plugins { + alias(libs.plugins.baselineprofile) alias(libs.plugins.nowinandroid.android.test) } @@ -34,23 +34,6 @@ android { buildConfig = true } - buildTypes { - // This benchmark buildType is used for benchmarking, and should function like your - // release build (for example, with minification on). It's signed with a debug key - // for easy local/CI testing. - create("benchmark") { - // Keep the build type debuggable so we can attach a debugger if needed. - isDebuggable = true - signingConfig = signingConfigs.getByName("debug") - matchingFallbacks.add("release") - buildConfigField( - "String", - "APP_BUILD_TYPE_SUFFIX", - "\"${NiaBuildType.BENCHMARK.applicationIdSuffix ?: ""}\"" - ) - } - } - // Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod, // which is more close to what will be shipped to users (no fake data), but has ability to run the // benchmarks on demo, so we benchmark on stable data. @@ -62,10 +45,27 @@ android { ) } + testOptions.managedDevices.devices { + create("pixel6Api33") { + device = "Pixel 6" + apiLevel = 33 + systemImageSource = "aosp" + } + } + targetProjectPath = ":app" experimentalProperties["android.experimental.self-instrumenting"] = true } +baselineProfile { + // This specifies the managed devices to use that you run the tests on. + managedDevices += "pixel6Api33" + + // Don't use a connected device but rely on a GMD for consistency between local and CI builds. + useConnectedDevices = false + +} + dependencies { implementation(libs.androidx.benchmark.macro) implementation(libs.androidx.test.core) @@ -75,9 +75,3 @@ dependencies { implementation(libs.androidx.test.runner) implementation(libs.androidx.test.uiautomator) } - -androidComponents { - beforeVariants { - it.enable = it.buildType == "benchmark" - } -} diff --git a/benchmarks/src/main/kotlin/androidx/test/uiautomator/UiAutomatorHelpers.kt b/benchmarks/src/main/kotlin/androidx/test/uiautomator/UiAutomatorHelpers.kt index 85867b982..b0eb754c7 100644 --- a/benchmarks/src/main/kotlin/androidx/test/uiautomator/UiAutomatorHelpers.kt +++ b/benchmarks/src/main/kotlin/androidx/test/uiautomator/UiAutomatorHelpers.kt @@ -29,15 +29,11 @@ import androidx.test.uiautomator.HasChildrenOp.EXACTLY fun untilHasChildren( childCount: Int = 1, op: HasChildrenOp = AT_LEAST, -): UiObject2Condition { - return object : UiObject2Condition() { - override fun apply(element: UiObject2): Boolean { - return when (op) { - AT_LEAST -> element.childCount >= childCount - EXACTLY -> element.childCount == childCount - AT_MOST -> element.childCount <= childCount - } - } +): UiObject2Condition = object : UiObject2Condition() { + override fun apply(element: UiObject2): Boolean = when (op) { + AT_LEAST -> element.childCount >= childCount + EXACTLY -> element.childCount == childCount + AT_MOST -> element.childCount <= childCount } } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt index 48472e523..8df52104a 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt @@ -20,6 +20,10 @@ import android.Manifest.permission import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until /** * Because the app under test is different from the one running the instrumentation test, @@ -42,3 +46,27 @@ fun MacrobenchmarkScope.allowNotifications() { device.executeShellCommand(command) } } + +/** + * Wraps starting the default activity, waiting for it to start and then allowing notifications in + * one convenient call. + */ +fun MacrobenchmarkScope.startActivityAndAllowNotifications() { + startActivityAndWait() + allowNotifications() +} + +/** + * Waits for and returns the `niaTopAppBar` + */ +fun MacrobenchmarkScope.getTopAppBar(): UiObject2 { + device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) + return device.findObject(By.res("niaTopAppBar")) +} + +/** + * Waits for an object on the top app bar, passed in as [selector]. + */ +fun MacrobenchmarkScope.waitForObjectOnTopAppBar(selector: BySelector, timeout: Long = 2_000) { + getTopAppBar().wait(Until.hasObject(selector), timeout) +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/Utils.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/Utils.kt index 9ece991c4..e8fb53c4f 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/Utils.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/Utils.kt @@ -30,7 +30,6 @@ import java.io.ByteArrayOutputStream val PACKAGE_NAME = buildString { append("com.google.samples.apps.nowinandroid") append(BuildConfig.APP_FLAVOR_SUFFIX) - append(BuildConfig.APP_BUILD_TYPE_SUFFIX) } fun UiDevice.flingElementDownUp(element: UiObject2) { diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BookmarksBaselineProfile.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BookmarksBaselineProfile.kt new file mode 100644 index 000000000..eca3f059b --- /dev/null +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BookmarksBaselineProfile.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.baselineprofile + +import androidx.benchmark.macro.junit4.BaselineProfileRule +import com.google.samples.apps.nowinandroid.PACKAGE_NAME +import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications +import org.junit.Rule +import org.junit.Test + +/** + * Baseline Profile of the "Bookmarks" screen + */ +class BookmarksBaselineProfile { + @get:Rule val baselineProfileRule = BaselineProfileRule() + + @Test + fun generate() = + baselineProfileRule.collect(PACKAGE_NAME) { + startActivityAndAllowNotifications() + + // Navigate to saved screen + goToBookmarksScreen() + } +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/ForYouBaselineProfile.kt similarity index 59% rename from benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt rename to benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/ForYouBaselineProfile.kt index fcbbc1049..e8722e116 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/ForYouBaselineProfile.kt @@ -18,43 +18,27 @@ package com.google.samples.apps.nowinandroid.baselineprofile import androidx.benchmark.macro.junit4.BaselineProfileRule import com.google.samples.apps.nowinandroid.PACKAGE_NAME -import com.google.samples.apps.nowinandroid.allowNotifications -import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent -import com.google.samples.apps.nowinandroid.interests.goToInterestsScreen -import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications import org.junit.Rule import org.junit.Test /** - * Generates a baseline profile which can be copied to `app/src/main/baseline-prof.txt`. + * Baseline Profile of the "For You" screen */ -class BaselineProfileGenerator { +class ForYouBaselineProfile { @get:Rule val baselineProfileRule = BaselineProfileRule() @Test fun generate() = baselineProfileRule.collect(PACKAGE_NAME) { - // This block defines the app's critical user journey. Here we are interested in - // optimizing for app startup. But you can also navigate and scroll - // through your most important UI. - allowNotifications() - pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Scroll the feed critical user journey forYouWaitForContent() forYouSelectTopics(true) forYouScrollFeedDownUp() - - // Navigate to saved screen - goToBookmarksScreen() - - // Navigate to interests screen - goToInterestsScreen() - interestsScrollTopicsDownUp() } } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/InterestsBaselineProfile.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/InterestsBaselineProfile.kt new file mode 100644 index 000000000..dd2166dc2 --- /dev/null +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/InterestsBaselineProfile.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.baselineprofile + +import androidx.benchmark.macro.junit4.BaselineProfileRule +import com.google.samples.apps.nowinandroid.PACKAGE_NAME +import com.google.samples.apps.nowinandroid.interests.goToInterestsScreen +import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications +import org.junit.Rule +import org.junit.Test + +/** + * Baseline Profile of the "Interests" screen + */ +class InterestsBaselineProfile { + @get:Rule val baselineProfileRule = BaselineProfileRule() + + @Test + fun generate() = + baselineProfileRule.collect(PACKAGE_NAME) { + startActivityAndAllowNotifications() + + // Navigate to interests screen + goToInterestsScreen() + interestsScrollTopicsDownUp() + } +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/StartupBaselineProfile.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/StartupBaselineProfile.kt new file mode 100644 index 000000000..d8128a670 --- /dev/null +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/StartupBaselineProfile.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.baselineprofile + +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.benchmark.macro.junit4.BaselineProfileRule +import com.google.samples.apps.nowinandroid.PACKAGE_NAME +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications +import org.junit.Rule +import org.junit.Test + +/** + * Baseline Profile for app startup. This profile also enables using [Dex Layout Optimizations](https://developer.android.com/topic/performance/baselineprofiles/dex-layout-optimizations) + * via the `includeInStartupProfile` parameter. + */ +class StartupBaselineProfile { + @get:Rule val baselineProfileRule = BaselineProfileRule() + + @Test + fun generate() = baselineProfileRule.collect( + PACKAGE_NAME, + includeInStartupProfile = true, + profileBlock = MacrobenchmarkScope::startActivityAndAllowNotifications, + ) +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt index f66fa27a2..eb01c7d1b 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt @@ -18,13 +18,13 @@ package com.google.samples.apps.nowinandroid.bookmarks import androidx.benchmark.macro.MacrobenchmarkScope import androidx.test.uiautomator.By -import androidx.test.uiautomator.Until +import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar fun MacrobenchmarkScope.goToBookmarksScreen() { - device.findObject(By.text("Saved")).click() + val savedSelector = By.text("Saved") + val savedButton = device.findObject(savedSelector) + savedButton.click() device.waitForIdle() // Wait until saved title are shown on screen - device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) - val topAppBar = device.findObject(By.res("niaTopAppBar")) - topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000) + waitForObjectOnTopAppBar(savedSelector) } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt index 672c3f52f..20b941a24 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt @@ -22,6 +22,8 @@ import androidx.test.uiautomator.Until import androidx.test.uiautomator.untilHasChildren import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.waitAndFindObject +import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar +import org.junit.Assert.fail fun MacrobenchmarkScope.forYouWaitForContent() { // Wait until content is loaded by checking if topics are loaded @@ -49,6 +51,9 @@ fun MacrobenchmarkScope.forYouSelectTopics(recheckTopicsIfChecked: Boolean = fal var visited = 0 while (visited < 3) { + if (topics.childCount == 0) { + fail("No topics found, can't generate profile for ForYou page.") + } // Selecting some topics, which will populate items in the feed. val topic = topics.children[index % topics.childCount] // Find the checkable element to figure out whether it's checked or not @@ -99,7 +104,5 @@ fun MacrobenchmarkScope.setAppTheme(isDark: Boolean) { device.findObject(By.text("OK")).click() // Wait until the top app bar is visible on screen - device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) - val topAppBar = device.findObject(By.res("niaTopAppBar")) - topAppBar.wait(Until.hasObject(By.text("Now in Android")), 2_000) + waitForObjectOnTopAppBar(By.text("Now in Android")) } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt index 18a7a717b..c74d79307 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt @@ -22,7 +22,7 @@ import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import com.google.samples.apps.nowinandroid.PACKAGE_NAME -import com.google.samples.apps.nowinandroid.allowNotifications +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -38,6 +38,9 @@ class ScrollForYouFeedBenchmark { @Test fun scrollFeedCompilationBaselineProfile() = scrollFeed(CompilationMode.Partial()) + @Test + fun scrollFeedCompilationFull() = scrollFeed(CompilationMode.Full()) + private fun scrollFeed(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( packageName = PACKAGE_NAME, metrics = listOf(FrameTimingMetric()), @@ -47,8 +50,7 @@ class ScrollForYouFeedBenchmark { setupBlock = { // Start the app pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() }, ) { forYouWaitForContent() diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt index c359ae87e..05b276faa 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt @@ -20,22 +20,21 @@ import androidx.benchmark.macro.MacrobenchmarkScope import androidx.test.uiautomator.By import androidx.test.uiautomator.Until import com.google.samples.apps.nowinandroid.flingElementDownUp -import com.google.samples.apps.nowinandroid.waitAndFindObject +import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar fun MacrobenchmarkScope.goToInterestsScreen() { device.findObject(By.text("Interests")).click() device.waitForIdle() // Wait until interests are shown on screen - device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) - val topAppBar = device.findObject(By.res("niaTopAppBar")) - topAppBar.wait(Until.hasObject(By.text("Interests")), 2_000) + waitForObjectOnTopAppBar(By.text("Interests")) // Wait until content is loaded by checking if interests are loaded device.wait(Until.gone(By.res("loadingWheel")), 5_000) } fun MacrobenchmarkScope.interestsScrollTopicsDownUp() { - val topicsList = device.waitAndFindObject(By.res("interests:topics"), 2_000) + device.wait(Until.hasObject(By.res("interests:topics")), 5_000) + val topicsList = device.findObject(By.res("interests:topics")) device.flingElementDownUp(topicsList) } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt index b43d3a84b..b53e2e05c 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt @@ -23,7 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.uiautomator.By import com.google.samples.apps.nowinandroid.PACKAGE_NAME -import com.google.samples.apps.nowinandroid.allowNotifications +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -47,8 +47,7 @@ class ScrollTopicListBenchmark { setupBlock = { // Start the app pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Navigate to interests screen device.findObject(By.text("Interests")).click() device.waitForIdle() diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListPowerMetricsBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListPowerMetricsBenchmark.kt index 13c6f55e3..f938fad62 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListPowerMetricsBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListPowerMetricsBenchmark.kt @@ -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 diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt index 0030386b7..faf0803f3 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt @@ -23,7 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.uiautomator.By import com.google.samples.apps.nowinandroid.PACKAGE_NAME -import com.google.samples.apps.nowinandroid.allowNotifications +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -47,8 +47,7 @@ class TopicsScreenRecompositionBenchmark { setupBlock = { // Start the app pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Navigate to interests screen device.findObject(By.text("Interests")).click() device.waitForIdle() diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt index 669e05b82..96bea89b8 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt @@ -26,6 +26,7 @@ import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.allowNotifications import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -41,32 +42,33 @@ class StartupBenchmark { val benchmarkRule = MacrobenchmarkRule() @Test - fun startupNoCompilation() = startup(CompilationMode.None()) + fun startupWithoutPreCompilation() = startup(CompilationMode.None()) @Test - fun startupBaselineProfileDisabled() = startup( + fun startupWithPartialCompilationAndDisabledBaselineProfile() = startup( CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1), ) @Test - fun startupBaselineProfile() = startup(CompilationMode.Partial(baselineProfileMode = Require)) + fun startupPrecompiledWithBaselineProfile() = + startup(CompilationMode.Partial(baselineProfileMode = Require)) @Test - fun startupFullCompilation() = startup(CompilationMode.Full()) + fun startupFullyPrecompiled() = startup(CompilationMode.Full()) private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( packageName = PACKAGE_NAME, metrics = listOf(StartupTimingMetric()), compilationMode = compilationMode, - iterations = 10, + // More iterations result in higher statistical significance. + iterations = 20, startupMode = COLD, setupBlock = { pressHome() allowNotifications() }, ) { - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Waits until the content is ready to capture Time To Full Display forYouWaitForContent() } diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 9230dd6b7..dc478a829 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -14,6 +14,7 @@ * limitations under the License. */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -28,18 +29,30 @@ java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } -tasks.withType().configureEach { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 } } dependencies { compileOnly(libs.android.gradlePlugin) + compileOnly(libs.android.tools.common) + compileOnly(libs.compose.gradlePlugin) compileOnly(libs.firebase.crashlytics.gradlePlugin) 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 { diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt index bb79715e4..a8b1b1779 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt @@ -18,14 +18,14 @@ import com.android.build.api.dsl.ApplicationExtension import com.google.samples.apps.nowinandroid.configureAndroidCompose import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.getByType class AndroidApplicationComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - pluginManager.apply("com.android.application") - // Screenshot Tests - pluginManager.apply("io.github.takahirom.roborazzi") + apply(plugin = "com.android.application") + apply(plugin = "org.jetbrains.kotlin.plugin.compose") val extension = extensions.getByType() configureAndroidCompose(extension) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index f73ed1478..f4d5bb0d0 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -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,11 +33,14 @@ class AndroidApplicationConventionPlugin : Plugin { apply("com.android.application") apply("org.jetbrains.kotlin.android") apply("nowinandroid.android.lint") + apply("com.dropbox.dependency-guard") } extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 34 + @Suppress("UnstableApiUsage") + testOptions.animationsDisabled = true configureGradleManagedDevices(this) } extensions.configure { @@ -47,4 +50,4 @@ class AndroidApplicationConventionPlugin : Plugin { } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationJacocoConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationJacocoConventionPlugin.kt index 4c3acc520..ac385b0d9 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationJacocoConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationJacocoConventionPlugin.kt @@ -15,6 +15,7 @@ */ import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.gradle.internal.dsl.BaseAppModuleExtension import com.google.samples.apps.nowinandroid.configureJacoco import org.gradle.api.Plugin import org.gradle.api.Project @@ -23,13 +24,15 @@ import org.gradle.kotlin.dsl.getByType class AndroidApplicationJacocoConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - with(pluginManager) { - apply("org.gradle.jacoco") - apply("com.android.application") + pluginManager.apply("jacoco") + val androidExtension = extensions.getByType() + + androidExtension.buildTypes.configureEach { + enableAndroidTestCoverage = true + enableUnitTestCoverage = true } - val extension = extensions.getByType() - configureJacoco(extension) + + configureJacoco(extensions.getByType()) } } - -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index cc42d60fd..024ec4fe0 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -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 { override fun apply(target: Project) { @@ -31,35 +30,20 @@ class AndroidFeatureConventionPlugin : Plugin { apply("nowinandroid.android.hilt") } extensions.configure { - defaultConfig { - testInstrumentationRunner = - "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" - } + testOptions.animationsDisabled = true configureGradleManagedDevices(this) } 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("androidx.tracing.ktx").get()) - add("implementation", libs.findLibrary("kotlinx.coroutines.android").get()) + add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get()) } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt index a0e81a27c..fcb4f823e 100644 --- a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -23,17 +23,13 @@ class AndroidHiltConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { + apply("com.google.devtools.ksp") apply("dagger.hilt.android.plugin") - // KAPT must go last to avoid build warnings. - // See: https://stackoverflow.com/questions/70550883/warning-the-following-options-were-not-recognized-by-any-processor-dagger-f - apply("org.jetbrains.kotlin.kapt") } dependencies { "implementation"(libs.findLibrary("hilt.android").get()) - "kapt"(libs.findLibrary("hilt.compiler").get()) - "kaptAndroidTest"(libs.findLibrary("hilt.compiler").get()) - "kaptTest"(libs.findLibrary("hilt.compiler").get()) + "ksp"(libs.findLibrary("hilt.compiler").get()) } } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index 05f442354..19fabf549 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -18,16 +18,14 @@ import com.android.build.gradle.LibraryExtension import com.google.samples.apps.nowinandroid.configureAndroidCompose import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.getByType -import org.gradle.kotlin.dsl.kotlin class AndroidLibraryComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - pluginManager.apply("com.android.library") - // Screenshot Tests - pluginManager.apply("io.github.takahirom.roborazzi") + apply(plugin = "com.android.library") + apply(plugin = "org.jetbrains.kotlin.plugin.compose") val extension = extensions.getByType() configureAndroidCompose(extension) diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index ef84cfbb4..71d818c0c 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -21,6 +21,7 @@ import com.google.samples.apps.nowinandroid.configureGradleManagedDevices import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configurePrintApksTask import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests +import com.google.samples.apps.nowinandroid.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure @@ -39,18 +40,23 @@ class AndroidLibraryConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 34 + defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testOptions.animationsDisabled = true 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 { configurePrintApksTask(this) disableUnnecessaryAndroidTests(target) } dependencies { - add("testImplementation", kotlin("test")) - add("testImplementation", project(":core:testing")) add("androidTestImplementation", kotlin("test")) - add("androidTestImplementation", project(":core:testing")) + add("testImplementation", kotlin("test")) + + add("implementation", libs.findLibrary("androidx.tracing.ktx").get()) } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryJacocoConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryJacocoConventionPlugin.kt index 86ca091c3..6f2ff60c5 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryJacocoConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryJacocoConventionPlugin.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.google.samples.apps.nowinandroid.configureJacoco import org.gradle.api.Plugin @@ -23,13 +25,15 @@ import org.gradle.kotlin.dsl.getByType class AndroidLibraryJacocoConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - with(pluginManager) { - apply("org.gradle.jacoco") - apply("com.android.library") + pluginManager.apply("jacoco") + val androidExtension = extensions.getByType() + + androidExtension.buildTypes.configureEach { + enableAndroidTestCoverage = true + enableUnitTestCoverage = true } - val extension = extensions.getByType() - configureJacoco(extension) + + configureJacoco(extensions.getByType()) } } - -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt index b67fb1b26..dbca79a5e 100644 --- a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -14,29 +14,30 @@ * limitations under the License. */ +import androidx.room.gradle.RoomExtension import com.google.devtools.ksp.gradle.KspExtension 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 { override fun apply(target: Project) { with(target) { + pluginManager.apply("androidx.room") pluginManager.apply("com.google.devtools.ksp") extensions.configure { + arg("room.generateKotlin", "true") + } + + extensions.configure { // 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 +47,4 @@ class AndroidRoomConventionPlugin : Plugin { } } } - - /** - * 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}") - } } \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 614d4f2d0..f16a8051a 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -18,34 +18,29 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies -import org.gradle.kotlin.dsl.withType -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension /** * Configure Compose-specific options */ internal fun Project.configureAndroidCompose( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString() - } - dependencies { 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()) + add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get()) + add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get()) } testOptions { @@ -56,35 +51,22 @@ internal fun Project.configureAndroidCompose( } } - tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters() - } - } -} + extensions.configure { + fun Provider.onlyIfTrue() = flatMap { provider { it.takeIf(String::toBoolean) } } + fun Provider<*>.relativeToRootProject(dir: String) = flatMap { + rootProject.layout.buildDirectory.dir(projectDir.toRelativeString(rootDir)) + }.map { it.dir(dir) } -private fun Project.buildComposeMetricsParameters(): List { - val metricParameters = mutableListOf() - val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics") - val relativePath = projectDir.relativeTo(rootDir) - val buildDir = layout.buildDirectory.get().asFile - val enableMetrics = (enableMetricsProvider.orNull == "true") - if (enableMetrics) { - val metricsFolder = buildDir.resolve("compose-metrics").resolve(relativePath) - metricParameters.add("-P") - metricParameters.add( - "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath - ) - } + project.providers.gradleProperty("enableComposeCompilerMetrics").onlyIfTrue() + .relativeToRootProject("compose-metrics") + .let(metricsDestination::set) + + project.providers.gradleProperty("enableComposeCompilerReports").onlyIfTrue() + .relativeToRootProject("compose-reports") + .let(reportsDestination::set) - val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports") - val enableReports = (enableReportsProvider.orNull == "true") - if (enableReports) { - val reportsFolder = buildDir.resolve("compose-reports").resolve(relativePath) - metricParameters.add("-P") - metricParameters.add( - "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath - ) + stabilityConfigurationFile = rootProject.layout.projectDirectory.file("compose_compiler_config.conf") + + enableStrongSkippingMode = true } - return metricParameters.toList() } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt index 4ed01ac6b..4447b8602 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt @@ -16,35 +16,44 @@ package com.google.samples.apps.nowinandroid +import com.android.SdkConstants import com.android.build.api.artifact.SingleArtifact import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.gradle.BaseExtension +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.assign 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 @@ -65,6 +74,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, @@ -73,27 +83,27 @@ 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 + @get:Input + abstract val updateBadgingTaskName: Property + override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP @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 updateBadging", - ) - } + 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()) } } @@ -105,42 +115,41 @@ fun Project.configureBadgingTasks( componentsExtension.onVariants { variant -> // Registers a new task to verify the app bundle. val capitalizedVariantName = variant.name.capitalized() + val generateBadgingTaskName = "generate${capitalizedVariantName}Badging" val generateBadging = - tasks.register("generate${capitalizedVariantName}Badging") { - apk.set( - variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE), - ) - aapt2Executable.set( - File( - baseExtension.sdkDirectory, - "build-tools/${baseExtension.buildToolsVersion}/aapt2", - ), + tasks.register(generateBadgingTaskName) { + apk = variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE) + + aapt2Executable = File( + baseExtension.sdkDirectory, + "${SdkConstants.FD_BUILD_TOOLS}/" + + "${baseExtension.buildToolsVersion}/" + + SdkConstants.FN_AAPT2, ) - badging.set( - project.layout.buildDirectory.file( - "outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt", - ), + + badging = project.layout.buildDirectory.file( + "outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt", ) + } - tasks.register("update${capitalizedVariantName}Badging") { + val updateBadgingTaskName = "update${capitalizedVariantName}Badging" + tasks.register(updateBadgingTaskName) { from(generateBadging.get().badging) into(project.layout.projectDirectory) } val checkBadgingTaskName = "check${capitalizedVariantName}Badging" tasks.register(checkBadgingTaskName) { - goldenBadging.set( - project.layout.projectDirectory.file("${variant.name}-badging.txt"), - ) - generatedBadging.set( - generateBadging.get().badging, - ) + goldenBadging = project.layout.projectDirectory.file("${variant.name}-badging.txt") + + generatedBadging = generateBadging.get().badging + + this.updateBadgingTaskName = updateBadgingTaskName + + output = project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName") - output.set( - project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName"), - ) } } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt index 6aa896444..f67e9093d 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt @@ -25,7 +25,7 @@ import org.gradle.kotlin.dsl.invoke * Configure project for Gradle managed devices */ internal fun configureGradleManagedDevices( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd") val pixel6 = DeviceConfig("Pixel 6", 31, "aosp") diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt index 596c4f579..972d539c6 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,9 +16,15 @@ package com.google.samples.apps.nowinandroid +import com.android.build.api.artifact.ScopedArtifact import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.ScopedArtifacts import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.ListProperty import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.withType @@ -32,13 +38,24 @@ private val coverageExclusions = listOf( "**/R.class", "**/R\$*.class", "**/BuildConfig.*", - "**/Manifest*.*" + "**/Manifest*.*", + "**/*_Hilt*.class", + "**/Hilt_*.class", ) private fun String.capitalize() = replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } +/** + * Creates a new task that generates a combined coverage report with data from local and + * instrumented tests. + * + * `create{variant}CombinedCoverageReport` + * + * Note that coverage data must exist before running the task. This allows us to run device + * tests on CI using a different Github Action or an external device farm. + */ internal fun Project.configureJacoco( androidComponentsExtension: AndroidComponentsExtension<*, *, *>, ) { @@ -46,37 +63,62 @@ internal fun Project.configureJacoco( toolVersion = libs.findVersion("jacoco").get().toString() } - val jacocoTestReport = tasks.create("jacocoTestReport") - androidComponentsExtension.onVariants { variant -> - val testTaskName = "test${variant.name.capitalize()}UnitTest" + val myObjFactory = project.objects val buildDir = layout.buildDirectory.get().asFile - val reportTask = tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) { - dependsOn(testTaskName) + val allJars: ListProperty = myObjFactory.listProperty(RegularFile::class.java) + val allDirectories: ListProperty = + myObjFactory.listProperty(Directory::class.java) + val reportTask = + tasks.register( + "create${variant.name.capitalize()}CombinedCoverageReport", + JacocoReport::class, + ) { - reports { - xml.required.set(true) - html.required.set(true) - } - - classDirectories.setFrom( - fileTree("$buildDir/tmp/kotlin-classes/${variant.name}") { - exclude(coverageExclusions) + classDirectories.setFrom( + allJars, + allDirectories.map { dirs -> + dirs.map { dir -> + myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions) + } + }, + ) + reports { + xml.required = true + html.required = true } - ) - sourceDirectories.setFrom(files("$projectDir/src/main/java", "$projectDir/src/main/kotlin")) - executionData.setFrom(file("$buildDir/jacoco/$testTaskName.exec")) - } + // TODO: This is missing files in src/debug/, src/prod, src/demo, src/demoDebug... + sourceDirectories.setFrom( + files( + "$projectDir/src/main/java", + "$projectDir/src/main/kotlin", + ), + ) + + executionData.setFrom( + project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest") + .matching { include("**/*.exec") }, + + project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest") + .matching { include("**/*.ec") }, + ) + } + - jacocoTestReport.dependsOn(reportTask) + variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT) + .use(reportTask) + .toGet( + ScopedArtifact.CLASSES, + { _ -> allJars }, + { _ -> allDirectories }, + ) } tasks.withType().configureEach { configure { // Required for JaCoCo + Robolectric // https://github.com/robolectric/robolectric/issues/2230 - // TODO: Consider removing if not we don't add Robolectric isIncludeNoLocationClasses = true // Required for JDK 11 with the above diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 04ee4e56e..bfb799595 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -20,17 +20,20 @@ import com.android.build.api.dsl.CommonExtension import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.provideDelegate -import org.gradle.kotlin.dsl.withType -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinTopLevelExtension /** * Configure base Kotlin with Android options */ internal fun Project.configureKotlinAndroid( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { compileSdk = 34 @@ -48,7 +51,7 @@ internal fun Project.configureKotlinAndroid( } } - configureKotlin() + configureKotlin() dependencies { add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get()) @@ -66,28 +69,26 @@ internal fun Project.configureKotlinJvm() { targetCompatibility = JavaVersion.VERSION_11 } - configureKotlin() + configureKotlin() } /** * Configure base Kotlin options */ -private fun Project.configureKotlin() { - // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 - tasks.withType().configureEach { - kotlinOptions { - // Set JVM target to 11 - jvmTarget = JavaVersion.VERSION_11.toString() - // Treat all Kotlin warnings as errors (disabled by default) - // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties - 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", - ) - } +private inline fun Project.configureKotlin() = configure { + // Treat all Kotlin warnings as errors (disabled by default) + // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties + val warningsAsErrors: String? by project + when (this) { + is KotlinAndroidProjectExtension -> compilerOptions + is KotlinJvmProjectExtension -> compilerOptions + else -> TODO("Unsupported project extension $this ${T::class}") + }.apply { + jvmTarget = JvmTarget.JVM_11 + allWarningsAsErrors = warningsAsErrors.toBoolean() + freeCompilerArgs.add( + // Enable experimental coroutines APIs, including Flow + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + ) } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaBuildType.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaBuildType.kt index 653506f51..e4f40840d 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaBuildType.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaBuildType.kt @@ -22,5 +22,4 @@ package com.google.samples.apps.nowinandroid enum class NiaBuildType(val applicationIdSuffix: String? = null) { DEBUG(".debug"), RELEASE, - BENCHMARK(".benchmark") } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt index 60d059ac0..633098604 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt @@ -20,7 +20,7 @@ enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: St } fun configureFlavors( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {} ) { commonExtension.apply { diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt index 6c08216cc..271fc51b7 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt @@ -30,7 +30,11 @@ 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.kotlin.dsl.assign +import org.gradle.work.DisableCachingByDefault import java.io.File internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) { @@ -50,22 +54,26 @@ internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtensio if (artifact != null && testSources != null) { tasks.register( "${variant.name}PrintTestApk", - PrintApkLocationTask::class.java + PrintApkLocationTask::class.java, ) { - apkFolder.set(artifact) - builtArtifactsLoader.set(loader) - variantName.set(variant.name) - sources.set(testSources) + apkFolder = artifact + builtArtifactsLoader = loader + variantName = variant.name + sources = testSources } } } } } +@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 @@ -79,14 +87,12 @@ internal abstract class PrintApkLocationTask : DefaultTask() { fun taskAction() { val hasFiles = sources.orNull?.any { directory -> directory.asFileTree.files.any { - it.isFile && it.parentFile.path.contains("build${File.separator}generated").not() + it.isFile && "build${File.separator}generated" !in it.parentFile.path } } ?: throw RuntimeException("Cannot check androidTest sources") // Don't print APK location if there are no androidTest source files - if (!hasFiles) { - return - } + if (!hasFiles) return val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get()) ?: throw RuntimeException("Cannot load APKs") @@ -95,4 +101,4 @@ internal abstract class PrintApkLocationTask : DefaultTask() { val apk = File(builtArtifacts.elements.single().outputFile).toPath() println(apk) } -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index 1efa3f8be..dffc0c0dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,13 +27,19 @@ buildscript { exclude(group = "com.google.protobuf") } } + } -// Lists all plugins used throughout the project without applying them. +// Lists all plugins used throughout the project 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.compose) 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 @@ -41,4 +47,6 @@ 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 + alias(libs.plugins.module.graph) apply true // Plugin applied to allow module graph generation } diff --git a/compose_compiler_config.conf b/compose_compiler_config.conf new file mode 100644 index 000000000..d47946206 --- /dev/null +++ b/compose_compiler_config.conf @@ -0,0 +1,11 @@ +// This file contains classes (with possible wildcards) that the Compose Compiler will treat as stable. +// It allows us to define classes that our not part of our codebase without wrapping them in a stable class. +// For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file + +// We always use immutable classes for our data model, to avoid running the Compose compiler +// in the module we declare it to be stable here. +com.google.samples.apps.nowinandroid.core.model.data.* + +// Java standard library classes +java.time.ZoneId +java.time.ZoneOffset diff --git a/core/analytics/README.md b/core/analytics/README.md new file mode 100644 index 000000000..d2bcd1ea7 --- /dev/null +++ b/core/analytics/README.md @@ -0,0 +1,3 @@ +# :core:analytics module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_analytics.svg) diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index 0f712085c..023574e6f 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -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) } diff --git a/core/analytics/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt index 78ebec9e5..4ad6b6dc2 100644 --- a/core/analytics/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt +++ b/core/analytics/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -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 } diff --git a/core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt b/core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt index 2ff022287..f570be4a9 100644 --- a/core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt +++ b/core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt @@ -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") } diff --git a/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt index 45ff65a71..27968701b 100644 --- a/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt +++ b/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -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 + } } } diff --git a/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt b/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt index 62d4d9bcb..cedab6732 100644 --- a/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt +++ b/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt @@ -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 { diff --git a/core/common/README.md b/core/common/README.md index ade22c076..96558bcc6 100644 --- a/core/common/README.md +++ b/core/common/README.md @@ -1,3 +1,3 @@ # :core:common module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_common.svg) diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index d539d1892..51ae627dc 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -24,6 +24,6 @@ android { } dependencies { - implementation(libs.kotlinx.coroutines.android) - testImplementation(projects.core.testing) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) } \ No newline at end of file diff --git a/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt b/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt index c265394a8..6e7ca6bb3 100644 --- a/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt +++ b/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt @@ -34,7 +34,7 @@ annotation class ApplicationScope @Module @InstallIn(SingletonComponent::class) -object CoroutineScopesModule { +internal object CoroutineScopesModule { @Provides @Singleton @ApplicationScope diff --git a/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/result/Result.kt b/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/result/Result.kt index 6ae12d634..22376d082 100644 --- a/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/result/Result.kt +++ b/core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/result/Result.kt @@ -23,15 +23,10 @@ import kotlinx.coroutines.flow.onStart sealed interface Result { data class Success(val data: T) : Result - data class Error(val exception: Throwable? = null) : Result + data class Error(val exception: Throwable) : Result data object Loading : Result } -fun Flow.asResult(): Flow> { - return this - .map> { - Result.Success(it) - } - .onStart { emit(Result.Loading) } - .catch { emit(Result.Error(it)) } -} +fun Flow.asResult(): Flow> = map> { Result.Success(it) } + .onStart { emit(Result.Loading) } + .catch { emit(Result.Error(it)) } diff --git a/core/common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml b/core/common/src/main/res/drawable-anydpi-v24/core_common_ic_nia_notification.xml similarity index 100% rename from core/common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml rename to core/common/src/main/res/drawable-anydpi-v24/core_common_ic_nia_notification.xml diff --git a/core/common/src/main/res/drawable-hdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-hdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-hdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-hdpi/core_common_ic_nia_notification.png diff --git a/core/common/src/main/res/drawable-mdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-mdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-mdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-mdpi/core_common_ic_nia_notification.png diff --git a/core/common/src/main/res/drawable-xhdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-xhdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-xhdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-xhdpi/core_common_ic_nia_notification.png diff --git a/core/common/src/main/res/drawable-xxhdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-xxhdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-xxhdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-xxhdpi/core_common_ic_nia_notification.png diff --git a/core/common/src/test/kotlin/com/google/samples/apps/nowinandroid/core/result/ResultKtTest.kt b/core/common/src/test/kotlin/com/google/samples/apps/nowinandroid/core/result/ResultKtTest.kt index 4f1229e9d..2c3c7b763 100644 --- a/core/common/src/test/kotlin/com/google/samples/apps/nowinandroid/core/result/ResultKtTest.kt +++ b/core/common/src/test/kotlin/com/google/samples/apps/nowinandroid/core/result/ResultKtTest.kt @@ -38,7 +38,7 @@ class ResultKtTest { when (val errorResult = awaitItem()) { is Result.Error -> assertEquals( "Test Done", - errorResult.exception?.message, + errorResult.exception.message, ) Result.Loading, is Result.Success, diff --git a/core/data-test/README.md b/core/data-test/README.md index 4f623e629..977ee10e4 100644 --- a/core/data-test/README.md +++ b/core/data-test/README.md @@ -1,3 +1,3 @@ # :core:data-test module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.svg) diff --git a/core/data-test/build.gradle.kts b/core/data-test/build.gradle.kts index 7ca3ecd0d..b166df288 100644 --- a/core/data-test/build.gradle.kts +++ b/core/data-test/build.gradle.kts @@ -24,6 +24,6 @@ android { dependencies { api(projects.core.data) - implementation(projects.core.testing) - implementation(projects.core.common) + + implementation(libs.hilt.android.testing) } diff --git a/core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt similarity index 56% rename from core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt index 3e3e1952e..5a21ae337 100644 --- a/core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,14 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain +package com.google.samples.apps.nowinandroid.core.data.test -import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.TimeZone import javax.inject.Inject -/** - * A use case which returns total count of *Fts tables - */ -class GetSearchContentsCountUseCase @Inject constructor( - private val searchContentsRepository: SearchContentsRepository, -) { - operator fun invoke(): Flow = - searchContentsRepository.getSearchContentsCount() +class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor { + override val currentTimeZone: Flow = flowOf(TimeZone.of("Europe/Warsaw")) } diff --git a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index 2ec2bcf9c..46158479c 100644 --- a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -22,12 +22,13 @@ import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRep import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeNewsRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeSearchContentsRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeTopicsRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import dagger.Binds import dagger.Module import dagger.hilt.components.SingletonComponent @@ -38,7 +39,7 @@ import dagger.hilt.testing.TestInstallIn components = [SingletonComponent::class], replaces = [DataModule::class], ) -interface TestDataModule { +internal interface TestDataModule { @Binds fun bindsTopicRepository( fakeTopicsRepository: FakeTopicsRepository, @@ -68,4 +69,7 @@ interface TestDataModule { fun bindsNetworkMonitor( networkMonitor: AlwaysOnlineNetworkMonitor, ): NetworkMonitor + + @Binds + fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt similarity index 93% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt index 39ad05d1e..0cdec6090 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.model.asEntity @@ -25,7 +25,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO -import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource +import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -41,7 +41,7 @@ import javax.inject.Inject */ class FakeNewsRepository @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, - private val datasource: FakeNiaNetworkDataSource, + private val datasource: DemoNiaNetworkDataSource, ) : NewsRepository { override fun getNewsResources( diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeRecentSearchRepository.kt similarity index 82% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeRecentSearchRepository.kt index fc649f3ec..b8d949efe 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeRecentSearchRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository @@ -25,11 +25,11 @@ import javax.inject.Inject /** * Fake implementation of the [RecentSearchRepository] */ -class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { - override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ } +internal class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit override fun getRecentSearchQueries(limit: Int): Flow> = flowOf(emptyList()) - override suspend fun clearRecentSearches() { /* no-op */ } + override suspend fun clearRecentSearches() = Unit } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeSearchContentsRepository.kt similarity index 83% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeSearchContentsRepository.kt index d15890a10..1feeb6dcc 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeSearchContentsRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.model.data.SearchResult @@ -25,9 +25,9 @@ import javax.inject.Inject /** * Fake implementation of the [SearchContentsRepository] */ -class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { +internal class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { - override suspend fun populateFtsData() { /* no-op */ } + override suspend fun populateFtsData() = Unit override fun searchContents(searchQuery: String): Flow = flowOf() override fun getSearchContentsCount(): Flow = flowOf(1) } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeTopicsRepository.kt similarity index 84% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeTopicsRepository.kt index 1ab9c9353..0b81dd309 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeTopicsRepository.kt @@ -14,14 +14,14 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO -import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource +import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -36,9 +36,9 @@ import javax.inject.Inject * This allows us to run the app with fake data, without needing an internet connection or working * backend. */ -class FakeTopicsRepository @Inject constructor( +internal class FakeTopicsRepository @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, - private val datasource: FakeNiaNetworkDataSource, + private val datasource: DemoNiaNetworkDataSource, ) : TopicsRepository { override fun getTopics(): Flow> = flow { emit( @@ -55,9 +55,8 @@ class FakeTopicsRepository @Inject constructor( ) }.flowOn(ioDispatcher) - override fun getTopic(id: String): Flow { - return getTopics().map { it.first { topic -> topic.id == id } } - } + override fun getTopic(id: String): Flow = getTopics() + .map { it.first { topic -> topic.id == id } } override suspend fun syncWith(synchronizer: Synchronizer) = true } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeUserDataRepository.kt similarity index 94% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeUserDataRepository.kt index a9da29b56..61ab422af 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeUserDataRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource @@ -43,7 +43,7 @@ class FakeUserDataRepository @Inject constructor( override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) = niaPreferencesDataSource.setTopicIdFollowed(followedTopicId, followed) - override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { + override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) { niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked) } diff --git a/core/data/README.md b/core/data/README.md index 905d74615..5d30f1638 100644 --- a/core/data/README.md +++ b/core/data/README.md @@ -1,3 +1,3 @@ # :core:data module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_data.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_data.svg) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index e730a9eb3..142637ff9 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -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) } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/SyncUtilities.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/SyncUtilities.kt index 5d069dbaf..878c8ee2f 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/SyncUtilities.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/SyncUtilities.kt @@ -19,8 +19,6 @@ package com.google.samples.apps.nowinandroid.core.data import android.util.Log import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlin.coroutines.cancellation.CancellationException /** @@ -104,29 +102,3 @@ suspend fun Synchronizer.changeListSync( versionUpdater(latestVersion) } }.isSuccess - -/** - * Returns a [Flow] whose values are generated by [transform] function that process the most - * recently emitted values by each flow. - */ -fun combine( - flow: Flow, - flow2: Flow, - flow3: Flow, - flow4: Flow, - flow5: Flow, - flow6: Flow, - transform: suspend (T1, T2, T3, T4, T5, T6) -> R, -): Flow = combine( - combine(flow, flow2, flow3, ::Triple), - combine(flow4, flow5, flow6, ::Triple), -) { t1, t2 -> - transform( - t1.first, - t1.second, - t1.third, - t2.first, - t2.second, - t2.third, - ) -} diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt index 26f0bbc51..fa4bde8b8 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt @@ -28,6 +28,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneBroadcastMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -35,35 +37,38 @@ 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 + + @Binds + internal abstract fun binds(impl: TimeZoneBroadcastMonitor): TimeZoneMonitor } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt index 1a7a80fff..7f4e27b41 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt @@ -25,7 +25,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -interface UserNewsResourceRepositoryModule { +internal interface UserNewsResourceRepositoryModule { @Binds fun bindsUserNewsResourceRepository( userDataRepository: CompositeUserNewsResourceRepository, diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt index d36f509d9..3d2f657dd 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt @@ -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), diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt index 702c2dcd2..32239362d 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt @@ -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) { @@ -39,9 +39,7 @@ class DefaultRecentSearchRepository @Inject constructor( override fun getRecentSearchQueries(limit: Int): Flow> = recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries -> - searchQueries.map { - it.asExternalModel() - } + searchQueries.map { it.asExternalModel() } } override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt index dc3caa143..3bacb8a14 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt @@ -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, diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index ce395ad1c..d33c904e5 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -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, diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt index 13dd19e49..5c8cecce8 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt @@ -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 { diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index a6e41c9da..089b7087d 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -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 { @@ -42,7 +42,7 @@ class OfflineFirstUserDataRepository @Inject constructor( analyticsHelper.logTopicFollowToggled(followedTopicId, followed) } - override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { + override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) { niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked) analyticsHelper.logNewsResourceBookmarkToggled( newsResourceId = newsResourceId, diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt index ff616c179..c5202b02b 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt @@ -41,7 +41,7 @@ interface UserDataRepository { /** * Updates the bookmarked status for a news resource */ - suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) + suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) /** * Updates the viewed status for a news resource diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt index c88125be8..b2a642cf9 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt @@ -26,57 +26,68 @@ import android.net.NetworkRequest.Builder import android.os.Build.VERSION import android.os.Build.VERSION_CODES import androidx.core.content.getSystemService +import androidx.tracing.trace +import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn import javax.inject.Inject -class ConnectivityManagerNetworkMonitor @Inject constructor( +internal class ConnectivityManagerNetworkMonitor @Inject constructor( @ApplicationContext private val context: Context, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, ) : NetworkMonitor { override val isOnline: Flow = callbackFlow { - val connectivityManager = context.getSystemService() - if (connectivityManager == null) { - channel.trySend(false) - channel.close() - return@callbackFlow - } + trace("NetworkMonitor.callbackFlow") { + val connectivityManager = context.getSystemService() + if (connectivityManager == null) { + channel.trySend(false) + channel.close() + return@callbackFlow + } - /** - * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest], - * not just the active network. So we can simply track the presence (or absence) of such [Network]. - */ - val callback = object : NetworkCallback() { + /** + * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest], + * not just the active network. So we can simply track the presence (or absence) of such [Network]. + */ + val callback = object : NetworkCallback() { - private val networks = mutableSetOf() + private val networks = mutableSetOf() - override fun onAvailable(network: Network) { - networks += network - channel.trySend(true) - } + override fun onAvailable(network: Network) { + networks += network + channel.trySend(true) + } - override fun onLost(network: Network) { - networks -= network - channel.trySend(networks.isNotEmpty()) + override fun onLost(network: Network) { + networks -= network + channel.trySend(networks.isNotEmpty()) + } } - } - val request = Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - connectivityManager.registerNetworkCallback(request, callback) + trace("NetworkMonitor.registerNetworkCallback") { + val request = Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + } - /** - * Sends the latest connectivity status to the underlying channel. - */ - channel.trySend(connectivityManager.isCurrentlyConnected()) + /** + * Sends the latest connectivity status to the underlying channel. + */ + channel.trySend(connectivityManager.isCurrentlyConnected()) - awaitClose { - connectivityManager.unregisterNetworkCallback(callback) + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } } } + .flowOn(ioDispatcher) .conflate() @Suppress("DEPRECATION") diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt new file mode 100644 index 000000000..031bc9388 --- /dev/null +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.util + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import androidx.tracing.trace +import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO +import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.shareIn +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toKotlinTimeZone +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Utility for reporting current timezone the device has set. + * It always emits at least once with default setting and then for each TZ change. + */ +interface TimeZoneMonitor { + val currentTimeZone: Flow +} + +@Singleton +internal class TimeZoneBroadcastMonitor @Inject constructor( + @ApplicationContext private val context: Context, + @ApplicationScope appScope: CoroutineScope, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : TimeZoneMonitor { + + override val currentTimeZone: SharedFlow = + callbackFlow { + // Send the default time zone first. + trySend(TimeZone.currentSystemDefault()) + + // Registers BroadcastReceiver for the TimeZone changes + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_TIMEZONE_CHANGED) return + + val zoneIdFromIntent = if (VERSION.SDK_INT < VERSION_CODES.R) { + null + } else { + // Starting Android R we also get the new TimeZone. + intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.let { timeZoneId -> + // We need to convert it from java.util.Timezone to java.time.ZoneId + val zoneId = ZoneId.of(timeZoneId, ZoneId.SHORT_IDS) + // Convert to kotlinx.datetime.TimeZone + zoneId.toKotlinTimeZone() + } + } + + // If there isn't a zoneId in the intent, fallback to the systemDefault, which should also reflect the change + trySend(zoneIdFromIntent ?: TimeZone.currentSystemDefault()) + } + } + + trace("TimeZoneBroadcastReceiver.register") { + context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)) + } + + // Send here again, because registering the Broadcast Receiver can take up to several milliseconds. + // This way, we can reduce the likelihood that a TZ change wouldn't be caught with the Broadcast Receiver. + trySend(TimeZone.currentSystemDefault()) + + awaitClose { + context.unregisterReceiver(receiver) + } + } + // We use to prevent multiple emissions of the same type, because we use trySend multiple times. + .distinctUntilChanged() + .conflate() + .flowOn(ioDispatcher) + // Sharing the callback to prevent multiple BroadcastReceivers being registered + .shareIn(appScope, SharingStarted.WhileSubscribed(5_000), 1) +} diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt index 743fb7e5c..05811f4be 100644 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt @@ -82,7 +82,7 @@ class CompositeUserNewsResourceRepositoryTest { // Check that only news resources with the given topic id are returned. assertEquals( sampleNewsResources - .filter { it.topics.contains(sampleTopic1) } + .filter { sampleTopic1 in it.topics } .mapToUserNewsResources(emptyUserData), userNewsResources.first(), ) @@ -104,7 +104,7 @@ class CompositeUserNewsResourceRepositoryTest { // Check that only news resources with the given topic id are returned. assertEquals( sampleNewsResources - .filter { it.topics.contains(sampleTopic1) } + .filter { sampleTopic1 in it.topics } .mapToUserNewsResources(userData), userNewsResources.first(), ) diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt index a21dee863..c7dfd99d0 100644 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt @@ -91,14 +91,14 @@ class UserNewsResourceTest { // Construct the expected FollowableTopic. val followableTopic = FollowableTopic( topic = topic, - isFollowed = userData.followedTopics.contains(topic.id), + isFollowed = topic.id in userData.followedTopics, ) assertTrue(userNewsResource.followableTopics.contains(followableTopic)) } // Check that the saved flag is set correctly. assertEquals( - userData.bookmarkedNewsResources.contains(newsResource1.id), + newsResource1.id in userData.bookmarkedNewsResources, userNewsResource.isSaved, ) } diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index 27e86f2f4..422e2cfb7 100644 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -133,7 +133,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() = testScope.runTest { - subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true) + subject.setNewsResourceBookmarked(newsResourceId = "0", bookmarked = true) assertEquals( setOf("0"), @@ -142,7 +142,7 @@ class OfflineFirstUserDataRepositoryTest { .first(), ) - subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true) + subject.setNewsResourceBookmarked(newsResourceId = "1", bookmarked = true) assertEquals( setOf("0", "1"), diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index 6e5c45305..a3e373918 100644 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -34,9 +34,7 @@ val nonPresentInterestsIds = setOf("2") */ class TestNewsResourceDao : NewsResourceDao { - private var entitiesStateFlow = MutableStateFlow( - emptyList(), - ) + private val entitiesStateFlow = MutableStateFlow(emptyList()) internal var topicCrossReferences: List = listOf() @@ -94,21 +92,6 @@ class TestNewsResourceDao : NewsResourceDao { result.map { it.entity.id } } - override suspend fun insertOrIgnoreNewsResources( - entities: List, - ): List { - entitiesStateFlow.update { oldValues -> - // Old values come first so new values don't overwrite them - (oldValues + entities) - .distinctBy(NewsResourceEntity::id) - .sortedWith( - compareBy(NewsResourceEntity::publishDate).reversed(), - ) - } - // Assume no conflicts on insert - return entities.map { it.id.toLong() } - } - override suspend fun upsertNewsResources(newsResourceEntities: List) { entitiesStateFlow.update { oldValues -> // New values come first so they overwrite old values @@ -131,7 +114,7 @@ class TestNewsResourceDao : NewsResourceDao { override suspend fun deleteNewsResources(ids: List) { val idSet = ids.toSet() entitiesStateFlow.update { entities -> - entities.filterNot { idSet.contains(it.id) } + entities.filterNot { it.id in idSet } } } } diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNiaNetworkDataSource.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNiaNetworkDataSource.kt index 8e248a3aa..7675af7e9 100644 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNiaNetworkDataSource.kt +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNiaNetworkDataSource.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.core.data.testdoubles import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource -import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource +import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic @@ -35,7 +35,7 @@ enum class CollectionType { */ class TestNiaNetworkDataSource : NiaNetworkDataSource { - private val source = FakeNiaNetworkDataSource( + private val source = DemoNiaNetworkDataSource( UnconfinedTestDispatcher(), Json { ignoreUnknownKeys = true }, ) @@ -91,11 +91,10 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource { } } -fun List.after(version: Int?): List = - when (version) { - null -> this - else -> this.filter { it.changeListVersion > version } - } +fun List.after(version: Int?): List = when (version) { + null -> this + else -> filter { it.changeListVersion > version } +} /** * Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null @@ -105,7 +104,7 @@ private fun List.matchIds( idGetter: (T) -> String, ) = when (ids) { null -> this - else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } } + else -> ids.toSet().let { idSet -> filter { idGetter(it) in idSet } } } /** diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt index a52cc86f6..d217f55d7 100644 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt @@ -28,20 +28,15 @@ import kotlinx.coroutines.flow.update */ class TestTopicDao : TopicDao { - private var entitiesStateFlow = MutableStateFlow( - emptyList(), - ) + private val entitiesStateFlow = MutableStateFlow(emptyList()) - override fun getTopicEntity(topicId: String): Flow { + override fun getTopicEntity(topicId: String): Flow = throw NotImplementedError("Unused in tests") - } - override fun getTopicEntities(): Flow> = - entitiesStateFlow + override fun getTopicEntities(): Flow> = entitiesStateFlow override fun getTopicEntities(ids: Set): Flow> = - getTopicEntities() - .map { topics -> topics.filter { it.id in ids } } + getTopicEntities().map { topics -> topics.filter { it.id in ids } } override suspend fun getOneOffTopicEntities(): List = emptyList() @@ -55,15 +50,11 @@ class TestTopicDao : TopicDao { override suspend fun upsertTopics(entities: List) { // Overwrite old values with new values - entitiesStateFlow.update { oldValues -> - (entities + oldValues).distinctBy(TopicEntity::id) - } + entitiesStateFlow.update { oldValues -> (entities + oldValues).distinctBy(TopicEntity::id) } } override suspend fun deleteTopics(ids: List) { val idSet = ids.toSet() - entitiesStateFlow.update { entities -> - entities.filterNot { idSet.contains(it.id) } - } + entitiesStateFlow.update { entities -> entities.filterNot { it.id in idSet } } } } diff --git a/core/database/README.md b/core/database/README.md index 5cf339aed..855eab53e 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -1,3 +1,3 @@ # :core:database module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_database.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_database.svg) diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index a1075286d..a9e38ead7 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -22,18 +22,15 @@ plugins { } android { - defaultConfig { - testInstrumentationRunner = - "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" - } namespace = "com.google.samples.apps.nowinandroid.core.database" } dependencies { - implementation(projects.core.model) + api(projects.core.model) - implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) - androidTestImplementation(projects.core.testing) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlinx.coroutines.test) } diff --git a/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt b/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt index 55e817618..535ab61a7 100644 --- a/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt +++ b/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt @@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant +import org.junit.After import org.junit.Before import org.junit.Test import kotlin.test.assertEquals @@ -48,6 +49,9 @@ class NewsResourceDaoTest { topicDao = db.topicDao() } + @After + fun closeDb() = db.close() + @Test fun newsResourceDao_fetches_items_by_descending_publish_date() = runTest { val newsResourceEntities = listOf( diff --git a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseMigrations.kt b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseMigrations.kt index 09e0849fe..4e396c9e2 100644 --- a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseMigrations.kt +++ b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseMigrations.kt @@ -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", diff --git a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt index fd6b75e49..87fd82af1 100644 --- a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt +++ b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt @@ -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 diff --git a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index 0ad1e4f7d..929b88ce6 100644 --- a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -96,12 +96,6 @@ interface NewsResourceDao { filterNewsIds: Set = emptySet(), ): Flow> - /** - * Inserts [entities] into the db if they don't exist, and ignores those that do - */ - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertOrIgnoreNewsResources(entities: List): List - /** * Inserts or updates [newsResourceEntities] in the db under the specified primary keys */ diff --git a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DaosModule.kt similarity index 89% rename from core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt rename to core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DaosModule.kt index 34840a733..e7456054e 100644 --- a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt +++ b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DaosModule.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.database +package com.google.samples.apps.nowinandroid.core.database.di +import com.google.samples.apps.nowinandroid.core.database.NiaDatabase import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao @@ -28,7 +29,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -object DaosModule { +internal object DaosModule { @Provides fun providesTopicsDao( database: NiaDatabase, diff --git a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseModule.kt b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DatabaseModule.kt similarity index 83% rename from core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseModule.kt rename to core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DatabaseModule.kt index 7d89cd1ac..d79d35948 100644 --- a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseModule.kt +++ b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DatabaseModule.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,11 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.database +package com.google.samples.apps.nowinandroid.core.database.di import android.content.Context import androidx.room.Room +import com.google.samples.apps.nowinandroid.core.database.NiaDatabase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,7 +28,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object DatabaseModule { +internal object DatabaseModule { @Provides @Singleton fun providesNiaDatabase( diff --git a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/util/InstantConverter.kt b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/util/InstantConverter.kt index 4e880886c..0b79c2099 100644 --- a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/util/InstantConverter.kt +++ b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/util/InstantConverter.kt @@ -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) diff --git a/core/datastore-proto/README.md b/core/datastore-proto/README.md new file mode 100644 index 000000000..19ed58239 --- /dev/null +++ b/core/datastore-proto/README.md @@ -0,0 +1,3 @@ +# :core:datastore-proto module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore_proto.svg) diff --git a/core/datastore-proto/build.gradle.kts b/core/datastore-proto/build.gradle.kts new file mode 100644 index 000000000..511518dde --- /dev/null +++ b/core/datastore-proto/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.protobuf) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.core.datastore.proto" +} + +// Setup protobuf configuration, generating lite Java and Kotlin classes +protobuf { + protoc { + artifact = libs.protobuf.protoc.get().toString() + } + generateProtoTasks { + all().forEach { task -> + task.builtins { + register("java") { + option("lite") + } + register("kotlin") { + option("lite") + } + } + } + } +} + +androidComponents.beforeVariants { + android.sourceSets.register(it.name) { + val buildDir = layout.buildDirectory.get().asFile + java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java")) + kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin")) + } +} + +dependencies { + api(libs.protobuf.kotlin.lite) +} diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto b/core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto similarity index 100% rename from core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto rename to core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto b/core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto similarity index 100% rename from core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto rename to core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto similarity index 100% rename from core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto rename to core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto diff --git a/core/datastore-test/README.md b/core/datastore-test/README.md index 45cc51a48..99cf13f1f 100644 --- a/core/datastore-test/README.md +++ b/core/datastore-test/README.md @@ -1,3 +1,3 @@ # :core:datastore-test module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore_test.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore_test.svg) diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index 04b15f044..53e5e2c0c 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -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) } diff --git a/core/datastore-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt b/core/datastore-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt index b86003e83..295b2978a 100644 --- a/core/datastore-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt +++ b/core/datastore-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt @@ -35,7 +35,7 @@ import javax.inject.Singleton components = [SingletonComponent::class], replaces = [DataStoreModule::class], ) -object TestDataStoreModule { +internal object TestDataStoreModule { @Provides @Singleton diff --git a/core/datastore/README.md b/core/datastore/README.md index 56699a483..4785c5885 100644 --- a/core/datastore/README.md +++ b/core/datastore/README.md @@ -1,3 +1,3 @@ # :core:datastore module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore.svg) diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index f0cc9af47..34ea5ee78 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -18,7 +18,6 @@ plugins { alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library.jacoco) alias(libs.plugins.nowinandroid.android.hilt) - alias(libs.plugins.protobuf) } android { @@ -33,40 +32,13 @@ android { } } -// Setup protobuf configuration, generating lite Java and Kotlin classes -protobuf { - protoc { - artifact = libs.protobuf.protoc.get().toString() - } - generateProtoTasks { - all().forEach { task -> - task.builtins { - register("java") { - option("lite") - } - register("kotlin") { - option("lite") - } - } - } - } -} - -androidComponents.beforeVariants { - android.sourceSets.register(it.name) { - val buildDir = layout.buildDirectory.get().asFile - java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java")) - kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin")) - } -} - 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) } diff --git a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/IntToStringIdsMigration.kt b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/IntToStringIdsMigration.kt index 98632c652..ef9c1dd03 100644 --- a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/IntToStringIdsMigration.kt +++ b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/IntToStringIdsMigration.kt @@ -21,7 +21,7 @@ import androidx.datastore.core.DataMigration /** * Migrates saved ids from [Int] to [String] types */ -object IntToStringIdsMigration : DataMigration { +internal object IntToStringIdsMigration : DataMigration { override suspend fun cleanUp() = Unit diff --git a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/ListToMapMigration.kt b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/ListToMapMigration.kt index 5bff23340..5675aee05 100644 --- a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/ListToMapMigration.kt +++ b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/ListToMapMigration.kt @@ -21,7 +21,7 @@ import androidx.datastore.core.DataMigration /** * Migrates from using lists to maps for user data. */ -object ListToMapMigration : DataMigration { +internal object ListToMapMigration : DataMigration { override suspend fun cleanUp() = Unit @@ -52,7 +52,6 @@ object ListToMapMigration : DataMigration { hasDoneListToMapMigration = true } - override suspend fun shouldMigrate(currentData: UserPreferences): Boolean { - return !currentData.hasDoneListToMapMigration - } + override suspend fun shouldMigrate(currentData: UserPreferences): Boolean = + !currentData.hasDoneListToMapMigration } diff --git a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt index 6dc7725c1..9a76a75a1 100644 --- a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt +++ b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt @@ -103,9 +103,7 @@ class NiaPreferencesDataSource @Inject constructor( suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { userPreferences.updateData { - it.copy { - this.useDynamicColor = useDynamicColor - } + it.copy { this.useDynamicColor = useDynamicColor } } } @@ -190,9 +188,7 @@ class NiaPreferencesDataSource @Inject constructor( suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { userPreferences.updateData { - it.copy { - this.shouldHideOnboarding = shouldHideOnboarding - } + it.copy { this.shouldHideOnboarding = shouldHideOnboarding } } } } diff --git a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt index ac9eaf767..8e0d7d4d8 100644 --- a/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt @@ -41,7 +41,7 @@ object DataStoreModule { @Provides @Singleton - fun providesUserPreferencesDataStore( + internal fun providesUserPreferencesDataStore( @ApplicationContext context: Context, @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, @ApplicationScope scope: CoroutineScope, diff --git a/core/designsystem/README.md b/core/designsystem/README.md index 52a793821..d1778cb14 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -1,3 +1,3 @@ # :core:designsystem module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_designsystem.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_designsystem.svg) diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 7bd1d12d8..31635865c 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -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 { @@ -33,14 +34,19 @@ dependencies { api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material3) + api(libs.androidx.compose.material3.adaptive) + api(libs.androidx.compose.material3.navigationSuite) api(libs.androidx.compose.runtime) - api(libs.androidx.compose.ui.tooling.preview) api(libs.androidx.compose.ui.util) - debugApi(libs.androidx.compose.ui.tooling) - - implementation(libs.androidx.core.ktx) implementation(libs.coil.kt.compose) - androidTestImplementation(projects.core.testing) + testImplementation(libs.androidx.compose.ui.test) + testImplementation(libs.androidx.compose.ui.testManifest) + + testImplementation(libs.hilt.android.testing) + testImplementation(libs.robolectric) + testImplementation(projects.core.screenshotTesting) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt index 966014fd9..795c88d72 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt @@ -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,19 +278,9 @@ fun NiaButtonPreview() { @ThemePreviews @Composable fun NiaOutlinedButtonPreview() { - NiaTheme() { - NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) { - NiaOutlinedButton(onClick = {}, text = { Text("Test button") }) - } - } -} - -@ThemePreviews -@Composable -fun NiaButtonPreview2() { NiaTheme { NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) { - NiaButton(onClick = {}, text = { Text("Test button") }) + NiaOutlinedButton(onClick = {}, text = { Text("Test button") }) } } } @@ -315,7 +305,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 diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt index d1b7d124d..fe34cfaed 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt @@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.designsystem.component import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon @@ -43,7 +42,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme * @param label The text label content. */ @Composable -@OptIn(ExperimentalMaterial3Api::class) fun NiaFilterChip( selected: Boolean, onSelectedChange: (Boolean) -> Unit, @@ -73,13 +71,15 @@ fun NiaFilterChip( }, shape = CircleShape, border = FilterChipDefaults.filterChipBorder( + enabled = enabled, + selected = selected, 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 } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt index cc352107b..1557cac06 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color.Companion.Unspecified import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale @@ -49,7 +50,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) } @@ -79,7 +80,7 @@ fun DynamicAsyncImage( contentScale = ContentScale.Crop, painter = if (isError.not() && !isLocalInspection) imageLoader else placeholder, contentDescription = contentDescription, - colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null, + colorFilter = if (iconTint != Unspecified) ColorFilter.tint(iconTint) else null, ) } } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt index 503342d30..43ec11f0b 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt @@ -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 } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt index 21dfbc8c3..ca168b4be 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt @@ -96,8 +96,8 @@ fun NiaLoadingWheel( animationSpec = infiniteRepeatable( animation = keyframes { durationMillis = ROTATION_TIME / 2 - progressLineColor at ROTATION_TIME / NUM_OF_LINES / 2 with LinearEasing - baseLineColor at ROTATION_TIME / NUM_OF_LINES with LinearEasing + progressLineColor at ROTATION_TIME / NUM_OF_LINES / 2 using LinearEasing + baseLineColor at ROTATION_TIME / NUM_OF_LINES using LinearEasing }, repeatMode = RepeatMode.Restart, initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index), diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt index f1db03f66..4ac19b482 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt @@ -23,10 +23,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.navigationsuite.ExperimentalMaterial3AdaptiveNavigationSuiteApi +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -53,12 +63,12 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme fun RowScope.NiaNavigationBarItem( selected: Boolean, onClick: () -> Unit, - icon: @Composable () -> Unit, modifier: Modifier = Modifier, - selectedIcon: @Composable () -> Unit = icon, enabled: Boolean = true, - label: @Composable (() -> Unit)? = null, alwaysShowLabel: Boolean = true, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + label: @Composable (() -> Unit)? = null, ) { NavigationBarItem( selected = selected, @@ -117,12 +127,12 @@ fun NiaNavigationBar( fun NiaNavigationRailItem( selected: Boolean, onClick: () -> Unit, - icon: @Composable () -> Unit, modifier: Modifier = Modifier, - selectedIcon: @Composable () -> Unit = icon, enabled: Boolean = true, - label: @Composable (() -> Unit)? = null, alwaysShowLabel: Boolean = true, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + label: @Composable (() -> Unit)? = null, ) { NavigationRailItem( selected = selected, @@ -165,9 +175,104 @@ fun NiaNavigationRail( ) } +/** + * Now in Android navigation suite scaffold with item and content slots. + * Wraps Material 3 [NavigationSuiteScaffold]. + * + * @param modifier Modifier to be applied to the navigation suite scaffold. + * @param navigationSuiteItems A slot to display multiple items via [NiaNavigationSuiteScope]. + * @param windowAdaptiveInfo The window adaptive info. + * @param content The app content inside the scaffold. + */ +@OptIn( + ExperimentalMaterial3AdaptiveNavigationSuiteApi::class, + ExperimentalMaterial3AdaptiveApi::class, +) +@Composable +fun NiaNavigationSuiteScaffold( + navigationSuiteItems: NiaNavigationSuiteScope.() -> Unit, + modifier: Modifier = Modifier, + windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), + content: @Composable () -> Unit, +) { + val layoutType = NavigationSuiteScaffoldDefaults + .calculateFromAdaptiveInfo(windowAdaptiveInfo) + val navigationSuiteItemColors = NavigationSuiteItemColors( + navigationBarItemColors = NavigationBarItemDefaults.colors( + selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = NiaNavigationDefaults.navigationContentColor(), + selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = NiaNavigationDefaults.navigationContentColor(), + indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(), + ), + navigationRailItemColors = NavigationRailItemDefaults.colors( + selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = NiaNavigationDefaults.navigationContentColor(), + selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = NiaNavigationDefaults.navigationContentColor(), + indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(), + ), + navigationDrawerItemColors = NavigationDrawerItemDefaults.colors( + selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = NiaNavigationDefaults.navigationContentColor(), + selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = NiaNavigationDefaults.navigationContentColor(), + ), + ) + + NavigationSuiteScaffold( + navigationSuiteItems = { + NiaNavigationSuiteScope( + navigationSuiteScope = this, + navigationSuiteItemColors = navigationSuiteItemColors, + ).run(navigationSuiteItems) + }, + layoutType = layoutType, + containerColor = Color.Transparent, + navigationSuiteColors = NavigationSuiteDefaults.colors( + navigationBarContentColor = NiaNavigationDefaults.navigationContentColor(), + navigationRailContainerColor = Color.Transparent, + ), + modifier = modifier, + ) { + content() + } +} + +/** + * A wrapper around [NavigationSuiteScope] to declare navigation items. + */ +@OptIn(ExperimentalMaterial3AdaptiveNavigationSuiteApi::class) +class NiaNavigationSuiteScope internal constructor( + private val navigationSuiteScope: NavigationSuiteScope, + private val navigationSuiteItemColors: NavigationSuiteItemColors, +) { + fun item( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + label: @Composable (() -> Unit)? = null, + ) = navigationSuiteScope.item( + selected = selected, + onClick = onClick, + icon = { + if (selected) { + selectedIcon() + } else { + icon() + } + }, + label = label, + colors = navigationSuiteItemColors, + modifier = modifier, + ) +} + @ThemePreviews @Composable -fun NiaNavigationPreview() { +fun NiaNavigationBarPreview() { val items = listOf("For you", "Saved", "Interests") val icons = listOf( NiaIcons.UpcomingBorder, @@ -205,6 +310,46 @@ fun NiaNavigationPreview() { } } +@ThemePreviews +@Composable +fun NiaNavigationRailPreview() { + val items = listOf("For you", "Saved", "Interests") + val icons = listOf( + NiaIcons.UpcomingBorder, + NiaIcons.BookmarksBorder, + NiaIcons.Grid3x3, + ) + val selectedIcons = listOf( + NiaIcons.Upcoming, + NiaIcons.Bookmarks, + NiaIcons.Grid3x3, + ) + + NiaTheme { + NiaNavigationRail { + items.forEachIndexed { index, item -> + NiaNavigationRailItem( + icon = { + Icon( + imageVector = icons[index], + contentDescription = item, + ) + }, + selectedIcon = { + Icon( + imageVector = selectedIcons[index], + contentDescription = item, + ) + }, + label = { Text(item) }, + selected = index == 0, + onClick = { }, + ) + } + } + } +} + /** * Now in Android navigation default values. */ diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt index 92cd9aa8f..74753ca9b 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt @@ -91,7 +91,7 @@ fun NiaTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface, indicator = { tabPositions -> - TabRowDefaults.Indicator( + TabRowDefaults.SecondaryIndicator( modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), height = 2.dp, color = MaterialTheme.colorScheme.onSurface, diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt index 8ca1588a8..290845936 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt @@ -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 } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt index 99f935d2a..f85c65677 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt @@ -34,15 +34,16 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun NiaTopAppBar( @StringRes titleRes: Int, navigationIcon: ImageVector, - navigationIconContentDescription: String?, + navigationIconContentDescription: String, actionIcon: ImageVector, - actionIconContentDescription: String?, + actionIconContentDescription: String, modifier: Modifier = Modifier, colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), onNavigationClick: () -> Unit = {}, @@ -77,11 +78,13 @@ fun NiaTopAppBar( @Preview("Top App Bar") @Composable private fun NiaTopAppBarPreview() { - NiaTopAppBar( - titleRes = android.R.string.untitled, - navigationIcon = NiaIcons.Search, - navigationIconContentDescription = "Navigation icon", - actionIcon = NiaIcons.MoreVert, - actionIconContentDescription = "Action icon", - ) + NiaTheme { + NiaTopAppBar( + titleRes = android.R.string.untitled, + navigationIcon = NiaIcons.Search, + navigationIconContentDescription = "Navigation icon", + actionIcon = NiaIcons.MoreVert, + actionIconContentDescription = "Action icon", + ) + } } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt index fa913cb27..1086e280b 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt @@ -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 @@ -65,10 +75,10 @@ private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L */ @Composable fun ScrollableState.DraggableScrollbar( - modifier: Modifier = Modifier, state: ScrollbarState, orientation: Orientation, onThumbMoved: (Float) -> Unit, + modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } Scrollbar( @@ -95,9 +105,9 @@ fun ScrollableState.DraggableScrollbar( */ @Composable fun ScrollableState.DecorativeScrollbar( - modifier: Modifier = Modifier, state: ScrollbarState, orientation: Orientation, + modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } Scrollbar( @@ -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() { + 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 { 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, } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt index c1281a4c0..002f36b31 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt @@ -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] */ @@ -189,16 +195,14 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { */ @Composable fun Scrollbar( - modifier: Modifier = Modifier, orientation: Orientation, state: ScrollbarState, - minThumbSize: Dp = 40.dp, + modifier: Modifier = Modifier, interactionSource: MutableInteractionSource? = null, - thumb: @Composable () -> Unit, + minThumbSize: Dp = 40.dp, onThumbMoved: ((Float) -> Unit)? = null, + thumb: @Composable () -> Unit, ) { - 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 } } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt index 7a0282bf7..3fcc8f2c0 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt @@ -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,62 +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 - -private inline fun List.floatSumOf(selector: (T) -> Float): Float { - var sum = 0f - for (element in this) { - sum += selector(element) - } - return sum + return state } + +private inline fun List.floatSumOf(selector: (T) -> Float): Float = + fold(initial = 0f) { accumulator, listItem -> accumulator + selector(listItem) } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt index 847580361..a267ec2ec 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt @@ -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 { diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt index 8db20689f..dfa68e772 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt @@ -17,11 +17,12 @@ package com.google.samples.apps.nowinandroid.core.designsystem.icon import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ShortText import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.Upcoming import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.BookmarkBorder import androidx.compose.material.icons.rounded.Bookmarks @@ -31,7 +32,6 @@ import androidx.compose.material.icons.rounded.Grid3x3 import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material.icons.rounded.ShortText import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.ViewDay import androidx.compose.ui.graphics.vector.ImageVector @@ -41,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector */ object NiaIcons { val Add = Icons.Rounded.Add - val ArrowBack = Icons.Rounded.ArrowBack + val ArrowBack = Icons.AutoMirrored.Rounded.ArrowBack val Bookmark = Icons.Rounded.Bookmark val BookmarkBorder = Icons.Rounded.BookmarkBorder val Bookmarks = Icons.Rounded.Bookmarks @@ -53,7 +53,7 @@ object NiaIcons { val Person = Icons.Rounded.Person val Search = Icons.Rounded.Search val Settings = Icons.Rounded.Settings - val ShortText = Icons.Rounded.ShortText + val ShortText = Icons.AutoMirrored.Rounded.ShortText val Upcoming = Icons.Rounded.Upcoming val UpcomingBorder = Icons.Outlined.Upcoming val ViewDay = Icons.Rounded.ViewDay diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Tint.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Tint.kt index 848c8d8f5..75ab3a8f6 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Tint.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Tint.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.graphics.Color */ @Immutable data class TintTheme( - val iconTint: Color? = null, + val iconTint: Color = Color.Unspecified, ) /** diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt index 0d3b06457..82d769863 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt @@ -19,6 +19,9 @@ package com.google.samples.apps.nowinandroid.core.designsystem.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.LineHeightStyle.Alignment +import androidx.compose.ui.text.style.LineHeightStyle.Trim import androidx.compose.ui.unit.sp /** @@ -60,12 +63,20 @@ internal val NiaTypography = Typography( fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = Alignment.Bottom, + trim = Trim.None, + ), ), titleLarge = TextStyle( fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = Alignment.Bottom, + trim = Trim.LastLineBottom, + ), ), titleMedium = TextStyle( fontWeight = FontWeight.Bold, @@ -79,11 +90,16 @@ internal val NiaTypography = Typography( lineHeight = 20.sp, letterSpacing = 0.1.sp, ), + // Default text style bodyLarge = TextStyle( fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = Alignment.Center, + trim = Trim.None, + ), ), bodyMedium = TextStyle( fontWeight = FontWeight.Normal, @@ -97,22 +113,37 @@ internal val NiaTypography = Typography( lineHeight = 16.sp, letterSpacing = 0.4.sp, ), + // Used for Button labelLarge = TextStyle( fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, + lineHeightStyle = LineHeightStyle( + alignment = Alignment.Center, + trim = Trim.LastLineBottom, + ), ), + // Used for Navigation items labelMedium = TextStyle( fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = Alignment.Center, + trim = Trim.LastLineBottom, + ), ), + // Used for Tag labelSmall = TextStyle( fontWeight = FontWeight.Medium, fontSize = 10.sp, - lineHeight = 16.sp, + lineHeight = 14.sp, letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = Alignment.Center, + trim = Trim.LastLineBottom, + ), ), ) diff --git a/core/designsystem/src/main/res/drawable/core_designsystem_ic_placeholder_default.xml b/core/designsystem/src/main/res/drawable/core_designsystem_ic_placeholder_default.xml new file mode 100644 index 000000000..f5d87a103 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/core_designsystem_ic_placeholder_default.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_placeholder_default.xml b/core/designsystem/src/main/res/drawable/ic_placeholder_default.xml deleted file mode 100644 index a00c2de22..000000000 --- a/core/designsystem/src/main/res/drawable/ic_placeholder_default.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt index d3349de80..e8cfd9a96 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt @@ -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 { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt index d3aa4224f..2f6ab5370 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt @@ -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 { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt index 510a0dcfc..7a6a92a1d 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt @@ -21,12 +21,15 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.FontScale +import androidx.compose.ui.test.ForcedSize import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.then import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.github.takahirom.roborazzi.captureRoboImage -import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme @@ -43,9 +46,9 @@ 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() { +class FilterChipScreenshotTests { @get:Rule val composeTestRule = createAndroidComposeRule() @@ -78,7 +81,10 @@ class FilterChipScreenshotTests() { CompositionLocalProvider( LocalInspectionMode provides true, ) { - TestHarness(fontScale = 2f, size = DpSize(80.dp, 40.dp)) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.FontScale(2f) then + DeviceConfigurationOverride.ForcedSize(DpSize(80.dp, 40.dp)), + ) { NiaTheme { NiaBackground { NiaFilterChip(selected = true, onSelectedChange = {}) { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt index f6d71fe77..0104cfd47 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt @@ -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 { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt index 412f42370..9bdaca670 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt @@ -37,9 +37,9 @@ 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() { +class LoadingWheelScreenshotTests { @get:Rule val composeTestRule = createAndroidComposeRule() @@ -66,7 +66,7 @@ class LoadingWheelScreenshotTests() { fun loadingWheelAnimation() { composeTestRule.mainClock.autoAdvance = false composeTestRule.setContent { - NiaTheme() { + NiaTheme { NiaLoadingWheel(contentDesc = "") } } diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt index 05976ba47..be2c6fa28 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt @@ -23,10 +23,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.FontScale import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.captureRoboImage -import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons @@ -44,9 +45,9 @@ 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() { +class NavigationScreenshotTests { @get:Rule val composeTestRule = createAndroidComposeRule() @@ -66,7 +67,9 @@ class NavigationScreenshotTests() { CompositionLocalProvider( LocalInspectionMode provides true, ) { - TestHarness(fontScale = 2f) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.FontScale(2f), + ) { NiaTheme { NiaNavigationBarExample("Looong item") } diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt index 76117db2e..8ab711505 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt @@ -22,10 +22,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.FontScale import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.captureRoboImage -import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme @@ -42,9 +43,9 @@ 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() { +class TabsScreenshotTests { @get:Rule val composeTestRule = createAndroidComposeRule() @@ -62,7 +63,9 @@ class TabsScreenshotTests() { CompositionLocalProvider( LocalInspectionMode provides true, ) { - TestHarness(fontScale = 2f) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.FontScale(2f), + ) { NiaTheme { NiaTabsExample("Looooong item") } diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt index 9db4d02a3..8a519942d 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt @@ -20,10 +20,11 @@ import androidx.activity.ComponentActivity import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.FontScale import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.captureRoboImage -import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions @@ -39,9 +40,9 @@ 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() { +class TagScreenshotTests { @get:Rule val composeTestRule = createAndroidComposeRule() @@ -61,7 +62,9 @@ class TagScreenshotTests() { CompositionLocalProvider( LocalInspectionMode provides true, ) { - TestHarness(fontScale = 2f) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.Companion.FontScale(2f), + ) { NiaTheme { NiaTopicTag(followed = true, onClick = {}) { Text("LOOOOONG TOPIC") diff --git a/core/designsystem/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt similarity index 90% rename from core/designsystem/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt rename to core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt index b10df053c..1044a5443 100644 --- a/core/designsystem/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt @@ -16,7 +16,8 @@ package com.google.samples.apps.nowinandroid.core.designsystem -import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme @@ -43,6 +44,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.TintTheme import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import kotlin.test.assertEquals /** @@ -53,6 +56,7 @@ import kotlin.test.assertEquals * [LocalBackgroundTheme] — have the expected values for a given theme mode, as specified by the * design system. */ +@RunWith(RobolectricTestRunner::class) class ThemeTest { @get:Rule @@ -219,60 +223,41 @@ class ThemeTest { } @Composable - private fun dynamicLightColorSchemeWithFallback(): ColorScheme { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicLightColorScheme(LocalContext.current) - } else { - LightDefaultColorScheme - } + private fun dynamicLightColorSchemeWithFallback(): ColorScheme = when { + SDK_INT >= VERSION_CODES.S -> dynamicLightColorScheme(LocalContext.current) + else -> LightDefaultColorScheme } @Composable - private fun dynamicDarkColorSchemeWithFallback(): ColorScheme { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicDarkColorScheme(LocalContext.current) - } else { - DarkDefaultColorScheme - } + private fun dynamicDarkColorSchemeWithFallback(): ColorScheme = when { + SDK_INT >= VERSION_CODES.S -> dynamicDarkColorScheme(LocalContext.current) + else -> DarkDefaultColorScheme } - private fun emptyGradientColors(colorScheme: ColorScheme): GradientColors { - return GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp)) - } + private fun emptyGradientColors(colorScheme: ColorScheme): GradientColors = + GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp)) - private fun defaultGradientColors(colorScheme: ColorScheme): GradientColors { - return GradientColors( - top = colorScheme.inverseOnSurface, - bottom = colorScheme.primaryContainer, - container = colorScheme.surface, - ) - } + private fun defaultGradientColors(colorScheme: ColorScheme): GradientColors = GradientColors( + top = colorScheme.inverseOnSurface, + bottom = colorScheme.primaryContainer, + container = colorScheme.surface, + ) - private fun dynamicGradientColorsWithFallback(colorScheme: ColorScheme): GradientColors { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - emptyGradientColors(colorScheme) - } else { - defaultGradientColors(colorScheme) - } + private fun dynamicGradientColorsWithFallback(colorScheme: ColorScheme): GradientColors = when { + SDK_INT >= VERSION_CODES.S -> emptyGradientColors(colorScheme) + else -> defaultGradientColors(colorScheme) } - private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme { - return BackgroundTheme( - color = colorScheme.surface, - tonalElevation = 2.dp, - ) - } + private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme = BackgroundTheme( + color = colorScheme.surface, + tonalElevation = 2.dp, + ) - private fun defaultTintTheme(): TintTheme { - return TintTheme() - } + private fun defaultTintTheme(): TintTheme = TintTheme() - private fun dynamicTintThemeWithFallback(colorScheme: ColorScheme): TintTheme { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - TintTheme(colorScheme.primary) - } else { - TintTheme() - } + private fun dynamicTintThemeWithFallback(colorScheme: ColorScheme): TintTheme = when { + SDK_INT >= VERSION_CODES.S -> TintTheme(colorScheme.primary) + else -> TintTheme() } /** diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt index 29404da79..5988ed592 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt @@ -16,16 +16,16 @@ package com.google.samples.apps.nowinandroid.core.designsystem -import android.R.string import androidx.activity.ComponentActivity import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.FontScale import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.captureRoboImage -import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme @@ -43,9 +43,9 @@ 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() { +class TopAppBarScreenshotTests { @get:Rule val composeTestRule = createAndroidComposeRule() @@ -63,7 +63,9 @@ class TopAppBarScreenshotTests() { CompositionLocalProvider( LocalInspectionMode provides true, ) { - TestHarness(fontScale = 2f) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.FontScale(2f), + ) { NiaTheme { NiaTopAppBarExample() } diff --git a/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_dynamic.png index c4a4d7440..67cafa03d 100644 Binary files a/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_notDynamic.png index 4eb46a8e6..3f187d9d2 100644 Binary files a/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Background/Background_dark_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/Background_light_androidTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Background/Background_light_androidTheme_notDynamic.png index d2914c451..ebcf62c08 100644 Binary files a/core/designsystem/src/test/screenshots/Background/Background_light_androidTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Background/Background_light_androidTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_dynamic.png index 9b4d62d86..7f910a34b 100644 Binary files a/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_notDynamic.png index 22eaf5833..912480c6a 100644 Binary files a/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Background/Background_light_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_dynamic.png index 35449ae14..a9b2c8694 100644 Binary files a/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_notDynamic.png index ce588b0ee..f88a672c4 100644 Binary files a/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Background/GradientBackground_dark_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/GradientBackground_light_androidTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Background/GradientBackground_light_androidTheme_notDynamic.png index d2914c451..ebcf62c08 100644 Binary files a/core/designsystem/src/test/screenshots/Background/GradientBackground_light_androidTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Background/GradientBackground_light_androidTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_dynamic.png index 84a3dcaa7..6fef6436a 100644 Binary files a/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_notDynamic.png index cc8ccd997..e619f1332 100644 Binary files a/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Background/GradientBackground_light_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_dark_defaultTheme_dynamic.png index 9ddd58d4b..cf0656fbd 100644 Binary files a/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_light_defaultTheme_dynamic.png index c867bf469..7774a18bc 100644 Binary files a/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Button/ButtonLeadingIcon_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Button/Button_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Button/Button_dark_defaultTheme_dynamic.png index 386760d24..01538b44b 100644 Binary files a/core/designsystem/src/test/screenshots/Button/Button_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Button/Button_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Button/Button_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Button/Button_light_defaultTheme_dynamic.png index d817e5c3f..fdbbb820d 100644 Binary files a/core/designsystem/src/test/screenshots/Button/Button_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Button/Button_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Button/OutlineButton_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Button/OutlineButton_dark_defaultTheme_dynamic.png index 4a8fe7a98..6fce27976 100644 Binary files a/core/designsystem/src/test/screenshots/Button/OutlineButton_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Button/OutlineButton_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Button/OutlineButton_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Button/OutlineButton_light_defaultTheme_dynamic.png index 4dce1bcab..c18a86878 100644 Binary files a/core/designsystem/src/test/screenshots/Button/OutlineButton_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Button/OutlineButton_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_dark_defaultTheme_dynamic.png index d73f023c8..4b5c91914 100644 Binary files a/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_light_defaultTheme_dynamic.png index e363c8dad..865368ca1 100644 Binary files a/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/FilterChip/FilterChipSelected_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/FilterChip/FilterChip_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/FilterChip/FilterChip_dark_defaultTheme_dynamic.png index 7c0371ddc..8f90977fd 100644 Binary files a/core/designsystem/src/test/screenshots/FilterChip/FilterChip_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/FilterChip/FilterChip_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/FilterChip/FilterChip_fontScale2.png b/core/designsystem/src/test/screenshots/FilterChip/FilterChip_fontScale2.png index 56ddf9792..2dc430ca8 100644 Binary files a/core/designsystem/src/test/screenshots/FilterChip/FilterChip_fontScale2.png and b/core/designsystem/src/test/screenshots/FilterChip/FilterChip_fontScale2.png differ diff --git a/core/designsystem/src/test/screenshots/FilterChip/FilterChip_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/FilterChip/FilterChip_light_defaultTheme_dynamic.png index c73df7f89..2f3749cf3 100644 Binary files a/core/designsystem/src/test/screenshots/FilterChip/FilterChip_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/FilterChip/FilterChip_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_dark_defaultTheme_dynamic.png index 5e732c373..fe4b54ae2 100644 Binary files a/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_light_defaultTheme_dynamic.png index b7eb3db4a..92079273a 100644 Binary files a/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/IconButton/IconButtonUnchecked_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_115.png b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_115.png index 6d023a207..5aa1eb89a 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_115.png and b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_115.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png index c125faccf..570474cc1 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png and b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_dark_defaultTheme_dynamic.png index 7109265c7..013aac763 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_light_defaultTheme_dynamic.png index e42f9855c..36d79ab6c 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_androidTheme_notDynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_androidTheme_notDynamic.png index ac0065cc7..022ea15eb 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_androidTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_androidTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_dynamic.png index 41dbc1aea..0a7be72c2 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_notDynamic.png index 2f1d9767c..ddc43ab6a 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_dark_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_androidTheme_notDynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_androidTheme_notDynamic.png index d90547cd8..071ab0a04 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_androidTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_androidTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_dynamic.png index 1541e6133..7170dec31 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_notDynamic.png index 6949a8908..6829b0f78 100644 Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/LoadingWheel/OverlayLoadingWheel_light_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_androidTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_androidTheme_notDynamic.png index a698536d8..b2a0fb99c 100644 Binary files a/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_androidTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_androidTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_dynamic.png index 01183397f..8836faebc 100644 Binary files a/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_notDynamic.png index 92317be34..a4abd2d5b 100644 Binary files a/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Navigation/Navigation_dark_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Navigation/Navigation_fontScale2.png b/core/designsystem/src/test/screenshots/Navigation/Navigation_fontScale2.png index a432cc93c..97bbb0892 100644 Binary files a/core/designsystem/src/test/screenshots/Navigation/Navigation_fontScale2.png and b/core/designsystem/src/test/screenshots/Navigation/Navigation_fontScale2.png differ diff --git a/core/designsystem/src/test/screenshots/Navigation/Navigation_light_androidTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Navigation/Navigation_light_androidTheme_notDynamic.png index 238b60c25..a526e36c7 100644 Binary files a/core/designsystem/src/test/screenshots/Navigation/Navigation_light_androidTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Navigation/Navigation_light_androidTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_dynamic.png index ff60eb547..5e27d2497 100644 Binary files a/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_notDynamic.png b/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_notDynamic.png index badd8e1c7..f5671cb14 100644 Binary files a/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_notDynamic.png and b/core/designsystem/src/test/screenshots/Navigation/Navigation_light_defaultTheme_notDynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Tabs/Tabs_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Tabs/Tabs_dark_defaultTheme_dynamic.png index dbfb08f1f..15cb061a0 100644 Binary files a/core/designsystem/src/test/screenshots/Tabs/Tabs_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Tabs/Tabs_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Tabs/Tabs_fontScale2.png b/core/designsystem/src/test/screenshots/Tabs/Tabs_fontScale2.png index 9a60923d9..f62ea3ced 100644 Binary files a/core/designsystem/src/test/screenshots/Tabs/Tabs_fontScale2.png and b/core/designsystem/src/test/screenshots/Tabs/Tabs_fontScale2.png differ diff --git a/core/designsystem/src/test/screenshots/Tabs/Tabs_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Tabs/Tabs_light_defaultTheme_dynamic.png index a11d82680..0564b3881 100644 Binary files a/core/designsystem/src/test/screenshots/Tabs/Tabs_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Tabs/Tabs_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Tag/Tag_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Tag/Tag_dark_defaultTheme_dynamic.png index 2d819edf7..13345c365 100644 Binary files a/core/designsystem/src/test/screenshots/Tag/Tag_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Tag/Tag_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/Tag/Tag_fontScale2.png b/core/designsystem/src/test/screenshots/Tag/Tag_fontScale2.png index fc66ddf58..475707556 100644 Binary files a/core/designsystem/src/test/screenshots/Tag/Tag_fontScale2.png and b/core/designsystem/src/test/screenshots/Tag/Tag_fontScale2.png differ diff --git a/core/designsystem/src/test/screenshots/Tag/Tag_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/Tag/Tag_light_defaultTheme_dynamic.png index 5683b4c6b..00144ba15 100644 Binary files a/core/designsystem/src/test/screenshots/Tag/Tag_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/Tag/Tag_light_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_dark_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_dark_defaultTheme_dynamic.png index 0e53746d9..1c2d9b3ec 100644 Binary files a/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_dark_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_dark_defaultTheme_dynamic.png differ diff --git a/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_fontScale2.png b/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_fontScale2.png index ed2f04eb1..234304db1 100644 Binary files a/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_fontScale2.png and b/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_fontScale2.png differ diff --git a/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_light_defaultTheme_dynamic.png b/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_light_defaultTheme_dynamic.png index 00e313d2b..fbf61adc4 100644 Binary files a/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_light_defaultTheme_dynamic.png and b/core/designsystem/src/test/screenshots/TopAppBar/TopAppBar_light_defaultTheme_dynamic.png differ diff --git a/core/domain/README.md b/core/domain/README.md new file mode 100644 index 000000000..cc6905846 --- /dev/null +++ b/core/domain/README.md @@ -0,0 +1,3 @@ +# :core:domain module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_domain.svg) diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 78cf5187a..191877459 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -16,7 +16,7 @@ plugins { alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library.jacoco) - kotlin("kapt") + id("com.google.devtools.ksp") } android { @@ -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) - kapt(libs.hilt.compiler) + implementation(libs.javax.inject) testImplementation(projects.core.testing) } \ No newline at end of file diff --git a/core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt b/core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt index c3c045d44..0167a3192 100644 --- a/core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt +++ b/core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt @@ -37,22 +37,20 @@ class GetFollowableTopicsUseCase @Inject constructor( * * @param sortBy - the field used to sort the topics. Default NONE = no sorting. */ - operator fun invoke(sortBy: TopicSortField = NONE): Flow> { - return combine( - userDataRepository.userData, - topicsRepository.getTopics(), - ) { userData, topics -> - val followedTopics = topics - .map { topic -> - FollowableTopic( - topic = topic, - isFollowed = topic.id in userData.followedTopics, - ) - } - when (sortBy) { - NAME -> followedTopics.sortedBy { it.topic.name } - else -> followedTopics + operator fun invoke(sortBy: TopicSortField = NONE): Flow> = combine( + userDataRepository.userData, + topicsRepository.getTopics(), + ) { userData, topics -> + val followedTopics = topics + .map { topic -> + FollowableTopic( + topic = topic, + isFollowed = topic.id in userData.followedTopics, + ) } + when (sortBy) { + NAME -> followedTopics.sortedBy { it.topic.name } + else -> followedTopics } } } diff --git a/core/model/README.md b/core/model/README.md index 5279064f2..efd0eec76 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -1,3 +1,3 @@ # :core:model module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_model.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_model.svg) diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 393e3aa7d..5d6cabfdf 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -19,5 +19,5 @@ plugins { } dependencies { - implementation(libs.kotlinx.datetime) + api(libs.kotlinx.datetime) } diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt index f130a70db..dcbcaa531 100644 --- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt @@ -17,5 +17,7 @@ package com.google.samples.apps.nowinandroid.core.model.data enum class DarkThemeConfig { - FOLLOW_SYSTEM, LIGHT, DARK + FOLLOW_SYSTEM, + LIGHT, + DARK, } diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt index cef319c5f..ae1d525c5 100644 --- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt @@ -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, ) diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt index d8953df3c..431d4b573 100644 --- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt @@ -17,5 +17,6 @@ package com.google.samples.apps.nowinandroid.core.model.data enum class ThemeBrand { - DEFAULT, ANDROID + DEFAULT, + ANDROID, } diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt index 4ea830022..a56bbcb8d 100644 --- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt @@ -45,14 +45,13 @@ data class UserNewsResource internal constructor( followableTopics = newsResource.topics.map { topic -> FollowableTopic( topic = topic, - isFollowed = userData.followedTopics.contains(topic.id), + isFollowed = topic.id in userData.followedTopics, ) }, - isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), - hasBeenViewed = userData.viewedNewsResources.contains(newsResource.id), + isSaved = newsResource.id in userData.bookmarkedNewsResources, + hasBeenViewed = newsResource.id in userData.viewedNewsResources, ) } -fun List.mapToUserNewsResources(userData: UserData): List { - return map { UserNewsResource(it, userData) } -} +fun List.mapToUserNewsResources(userData: UserData): List = + map { UserNewsResource(it, userData) } diff --git a/core/network/README.md b/core/network/README.md index cfd9fd369..516aa2d38 100644 --- a/core/network/README.md +++ b/core/network/README.md @@ -1,3 +1,3 @@ # :core:network module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_network.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_network.svg) diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index dce97031f..689a99e73 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -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) } diff --git a/core/network/lint.xml b/core/network/lint.xml index 59fd50bd5..07c0cae9e 100644 --- a/core/network/lint.xml +++ b/core/network/lint.xml @@ -20,6 +20,6 @@ java.lang.IllegalStateException: () -> kotlin.String at org.jetbrains.kotlin.asJava.classes.KtLightClassForFacadeImpl$Companion.createForFacadeNoCache --> - + diff --git a/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt b/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt index a6162a9cc..42c2ffe8f 100644 --- a/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt +++ b/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.core.network.di import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource -import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource +import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -25,8 +25,8 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -interface FlavoredNetworkModule { +internal interface FlavoredNetworkModule { @Binds - fun binds(impl: FakeNiaNetworkDataSource): NiaNetworkDataSource + fun binds(impl: DemoNiaNetworkDataSource): NiaNetworkDataSource } diff --git a/core/network/src/main/kotlin/JvmUnitTestFakeAssetManager.kt b/core/network/src/main/kotlin/JvmUnitTestDemoAssetManager.kt similarity index 88% rename from core/network/src/main/kotlin/JvmUnitTestFakeAssetManager.kt rename to core/network/src/main/kotlin/JvmUnitTestDemoAssetManager.kt index 79370d5a8..2ef418517 100644 --- a/core/network/src/main/kotlin/JvmUnitTestFakeAssetManager.kt +++ b/core/network/src/main/kotlin/JvmUnitTestDemoAssetManager.kt @@ -14,8 +14,7 @@ * limitations under the License. */ -import androidx.annotation.VisibleForTesting -import com.google.samples.apps.nowinandroid.core.network.fake.FakeAssetManager +import com.google.samples.apps.nowinandroid.core.network.demo.DemoAssetManager import java.io.File import java.io.InputStream import java.util.Properties @@ -25,8 +24,8 @@ import java.util.Properties * It must remain on the root package for an easier [Class.getResource] with relative paths. * @see UnitTestOptions */ -@VisibleForTesting -internal object JvmUnitTestFakeAssetManager : FakeAssetManager { + +internal object JvmUnitTestDemoAssetManager : DemoAssetManager { private val config = requireNotNull(javaClass.getResource("com/android/tools/test_config.properties")) { """ diff --git a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeAssetManager.kt b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoAssetManager.kt similarity index 81% rename from core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeAssetManager.kt rename to core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoAssetManager.kt index 53ad7d48d..e5fb07bb3 100644 --- a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeAssetManager.kt +++ b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoAssetManager.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,10 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.network.fake +package com.google.samples.apps.nowinandroid.core.network.demo import java.io.InputStream -fun interface FakeAssetManager { +fun interface DemoAssetManager { fun open(fileName: String): InputStream } diff --git a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSource.kt similarity index 91% rename from core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt rename to core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSource.kt index 6ef90ecff..b7c912c00 100644 --- a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt +++ b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +14,9 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.network.fake +package com.google.samples.apps.nowinandroid.core.network.demo -import JvmUnitTestFakeAssetManager +import JvmUnitTestDemoAssetManager import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource @@ -33,10 +33,10 @@ import javax.inject.Inject /** * [NiaNetworkDataSource] implementation that provides static news resources to aid development */ -class FakeNiaNetworkDataSource @Inject constructor( +class DemoNiaNetworkDataSource @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val networkJson: Json, - private val assets: FakeAssetManager = JvmUnitTestFakeAssetManager, + private val assets: DemoAssetManager = JvmUnitTestDemoAssetManager, ) : NiaNetworkDataSource { @OptIn(ExperimentalSerializationApi::class) diff --git a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt index 98534ba93..a97540f2b 100644 --- a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt @@ -17,11 +17,12 @@ package com.google.samples.apps.nowinandroid.core.network.di import android.content.Context +import androidx.tracing.trace import coil.ImageLoader import coil.decode.SvgDecoder import coil.util.DebugLogger import com.google.samples.apps.nowinandroid.core.network.BuildConfig -import com.google.samples.apps.nowinandroid.core.network.fake.FakeAssetManager +import com.google.samples.apps.nowinandroid.core.network.demo.DemoAssetManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -35,7 +36,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object NetworkModule { +internal object NetworkModule { @Provides @Singleton @@ -45,22 +46,24 @@ object NetworkModule { @Provides @Singleton - fun providesFakeAssetManager( + fun providesDemoAssetManager( @ApplicationContext context: Context, - ): FakeAssetManager = FakeAssetManager(context.assets::open) + ): DemoAssetManager = DemoAssetManager(context.assets::open) @Provides @Singleton - fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder() - .addInterceptor( - HttpLoggingInterceptor() - .apply { - if (BuildConfig.DEBUG) { - setLevel(HttpLoggingInterceptor.Level.BODY) - } - }, - ) - .build() + fun okHttpCallFactory(): Call.Factory = trace("NiaOkHttpClient") { + OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor() + .apply { + if (BuildConfig.DEBUG) { + setLevel(HttpLoggingInterceptor.Level.BODY) + } + }, + ) + .build() + } /** * Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this @@ -72,20 +75,21 @@ object NetworkModule { @Provides @Singleton fun imageLoader( - okHttpCallFactory: Call.Factory, + // We specifically request dagger.Lazy here, so that it's not instantiated from Dagger. + okHttpCallFactory: dagger.Lazy, @ApplicationContext application: Context, - ): ImageLoader = ImageLoader.Builder(application) - .callFactory(okHttpCallFactory) - .components { - add(SvgDecoder.Factory()) - } - // Assume most content images are versioned urls - // but some problematic images are fetching each time - .respectCacheHeaders(false) - .apply { - if (BuildConfig.DEBUG) { - logger(DebugLogger()) + ): ImageLoader = trace("NiaImageLoader") { + ImageLoader.Builder(application) + .callFactory { okHttpCallFactory.get() } + .components { add(SvgDecoder.Factory()) } + // Assume most content images are versioned urls + // but some problematic images are fetching each time + .respectCacheHeaders(false) + .apply { + if (BuildConfig.DEBUG) { + logger(DebugLogger()) + } } - } - .build() + .build() + } } diff --git a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt index b86036ff0..e9fe99d9e 100644 --- a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt +++ b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.network.retrofit +import androidx.tracing.trace import com.google.samples.apps.nowinandroid.core.network.BuildConfig import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList @@ -71,19 +72,23 @@ private data class NetworkResponse( * [Retrofit] backed [NiaNetworkDataSource] */ @Singleton -class RetrofitNiaNetwork @Inject constructor( +internal class RetrofitNiaNetwork @Inject constructor( networkJson: Json, - okhttpCallFactory: Call.Factory, + okhttpCallFactory: dagger.Lazy, ) : NiaNetworkDataSource { - private val networkApi = Retrofit.Builder() - .baseUrl(NIA_BASE_URL) - .callFactory(okhttpCallFactory) - .addConverterFactory( - networkJson.asConverterFactory("application/json".toMediaType()), - ) - .build() - .create(RetrofitNiaNetworkApi::class.java) + private val networkApi = trace("RetrofitNiaNetwork") { + Retrofit.Builder() + .baseUrl(NIA_BASE_URL) + // We use callFactory lambda here with dagger.Lazy + // to prevent initializing OkHttp on the main thread. + .callFactory { okhttpCallFactory.get().newCall(it) } + .addConverterFactory( + networkJson.asConverterFactory("application/json".toMediaType()), + ) + .build() + .create(RetrofitNiaNetworkApi::class.java) + } override suspend fun getTopics(ids: List?): List = networkApi.getTopics(ids = ids).data diff --git a/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt b/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt index 51a8a6f19..bff1ca5be 100644 --- a/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt +++ b/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt @@ -25,7 +25,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -interface FlavoredNetworkModule { +internal interface FlavoredNetworkModule { @Binds fun binds(impl: RetrofitNiaNetwork): NiaNetworkDataSource diff --git a/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSourceTest.kt b/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSourceTest.kt similarity index 85% rename from core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSourceTest.kt rename to core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSourceTest.kt index 76c2accf2..e60cfeb3e 100644 --- a/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSourceTest.kt +++ b/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSourceTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +14,9 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.network.fake +package com.google.samples.apps.nowinandroid.core.network.demo -import JvmUnitTestFakeAssetManager +import JvmUnitTestDemoAssetManager import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import kotlinx.coroutines.test.StandardTestDispatcher @@ -29,25 +29,25 @@ import org.junit.Before import org.junit.Test import kotlin.test.assertEquals -class FakeNiaNetworkDataSourceTest { +class DemoNiaNetworkDataSourceTest { - private lateinit var subject: FakeNiaNetworkDataSource + private lateinit var subject: DemoNiaNetworkDataSource private val testDispatcher = StandardTestDispatcher() @Before fun setUp() { - subject = FakeNiaNetworkDataSource( + subject = DemoNiaNetworkDataSource( ioDispatcher = testDispatcher, networkJson = Json { ignoreUnknownKeys = true }, - assets = JvmUnitTestFakeAssetManager, + assets = JvmUnitTestDemoAssetManager, ) } + @Suppress("ktlint:standard:max-line-length") @Test fun testDeserializationOfTopics() = runTest(testDispatcher) { assertEquals( - /* ktlint-disable max-line-length */ NetworkTopic( id = "1", name = "Headlines", @@ -56,15 +56,14 @@ class FakeNiaNetworkDataSourceTest { url = "", imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", ), - /* ktlint-enable max-line-length */ subject.getTopics().first(), ) } + @Suppress("ktlint:standard:max-line-length") @Test fun testDeserializationOfNewsResources() = runTest(testDispatcher) { assertEquals( - /* ktlint-disable max-line-length */ NetworkNewsResource( id = "125", title = "Android Basics with Compose", @@ -83,7 +82,6 @@ class FakeNiaNetworkDataSourceTest { type = "Codelab", topics = listOf("2", "3", "10"), ), - /* ktlint-enable max-line-length */ subject.getNewsResources().find { it.id == "125" }, ) } diff --git a/core/notifications/README.md b/core/notifications/README.md new file mode 100644 index 000000000..8f5607bdf --- /dev/null +++ b/core/notifications/README.md @@ -0,0 +1,3 @@ +# :core:notifications module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_notifications.svg) diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts index 31b15a805..92871b72b 100644 --- a/core/notifications/build.gradle.kts +++ b/core/notifications/build.gradle.kts @@ -15,7 +15,6 @@ */ plugins { alias(libs.plugins.nowinandroid.android.library) - alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.hilt) } @@ -24,14 +23,10 @@ android { } dependencies { - implementation(projects.core.common) - implementation(projects.core.model) + api(projects.core.model) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.androidx.browser) - implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.core.ktx) + implementation(projects.core.common) - implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.cloud.messaging) + compileOnly(platform(libs.androidx.compose.bom)) + compileOnly(libs.androidx.compose.runtime) } diff --git a/core/notifications/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt b/core/notifications/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt index 9bb2b3fb9..99ba10fa7 100644 --- a/core/notifications/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt +++ b/core/notifications/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt @@ -23,7 +23,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -abstract class NotificationsModule { +internal abstract class NotificationsModule { @Binds abstract fun bindNotifier( notifier: NoOpNotifier, diff --git a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt index d17005bca..863c1a662 100644 --- a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt +++ b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt @@ -22,6 +22,6 @@ import javax.inject.Inject /** * Implementation of [Notifier] which does nothing. Useful for tests and previews. */ -class NoOpNotifier @Inject constructor() : Notifier { +internal class NoOpNotifier @Inject constructor() : Notifier { override fun postNewsNotifications(newsResources: List) = Unit } diff --git a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt index 7e74f819c..1c9e7ab63 100644 --- a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt +++ b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt @@ -24,10 +24,10 @@ import android.app.PendingIntent import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build.VERSION import android.os.Build.VERSION_CODES -import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityCompat.checkSelfPermission import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.InboxStyle import androidx.core.app.NotificationManagerCompat @@ -50,46 +50,40 @@ private const val FOR_YOU_PATH = "foryou" * Implementation of [Notifier] that displays notifications in the system tray. */ @Singleton -class SystemTrayNotifier @Inject constructor( +internal class SystemTrayNotifier @Inject constructor( @ApplicationContext private val context: Context, ) : Notifier { override fun postNewsNotifications( newsResources: List, ) = with(context) { - if (ActivityCompat.checkSelfPermission( - this, - permission.POST_NOTIFICATIONS, - ) != PackageManager.PERMISSION_GRANTED - ) { + if (checkSelfPermission(this, permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED) { return } - val truncatedNewsResources = newsResources - .take(MAX_NUM_NOTIFICATIONS) + val truncatedNewsResources = newsResources.take(MAX_NUM_NOTIFICATIONS) - val newsNotifications = truncatedNewsResources - .map { newsResource -> - createNewsNotification { - setSmallIcon( - com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, - ) - .setContentTitle(newsResource.title) - .setContentText(newsResource.content) - .setContentIntent(newsPendingIntent(newsResource)) - .setGroup(NEWS_NOTIFICATION_GROUP) - .setAutoCancel(true) - } + val newsNotifications = truncatedNewsResources.map { newsResource -> + createNewsNotification { + setSmallIcon( + com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, + ) + .setContentTitle(newsResource.title) + .setContentText(newsResource.content) + .setContentIntent(newsPendingIntent(newsResource)) + .setGroup(NEWS_NOTIFICATION_GROUP) + .setAutoCancel(true) } + } val summaryNotification = createNewsNotification { val title = getString( - R.string.news_notification_group_summary, + R.string.core_notifications_news_notification_group_summary, truncatedNewsResources.size, ) setContentTitle(title) .setContentText(title) .setSmallIcon( - com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, + com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, ) // Build summary info into InboxStyle template. .setStyle(newsNotificationStyle(truncatedNewsResources, title)) @@ -117,9 +111,7 @@ class SystemTrayNotifier @Inject constructor( newsResources: List, title: String, ): InboxStyle = newsResources - .fold(InboxStyle()) { inboxStyle, newsResource -> - inboxStyle.addLine(newsResource.title) - } + .fold(InboxStyle()) { inboxStyle, newsResource -> inboxStyle.addLine(newsResource.title) } .setBigContentTitle(title) .setSummaryText(title) } @@ -148,10 +140,10 @@ private fun Context.ensureNotificationChannelExists() { val channel = NotificationChannel( NEWS_NOTIFICATION_CHANNEL_ID, - getString(R.string.news_notification_channel_name), + getString(R.string.core_notifications_news_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT, ).apply { - description = getString(R.string.news_notification_channel_description) + description = getString(R.string.core_notifications_news_notification_channel_description) } // Register the channel with the system NotificationManagerCompat.from(this).createNotificationChannel(channel) diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml index 5bb37b23a..88e492e77 100644 --- a/core/notifications/src/main/res/values/strings.xml +++ b/core/notifications/src/main/res/values/strings.xml @@ -15,7 +15,7 @@ limitations under the License. --> - News updates - The latest updates on what\'s new in Android - %1$d news updates + News updates + The latest updates on what\'s new in Android + %1$d news updates diff --git a/core/notifications/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt b/core/notifications/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt index 3c05e9c6e..c2e1f76ca 100644 --- a/core/notifications/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt +++ b/core/notifications/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt @@ -23,7 +23,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -abstract class NotificationsModule { +internal abstract class NotificationsModule { @Binds abstract fun bindNotifier( notifier: SystemTrayNotifier, diff --git a/core/screenshot-testing/.gitignore b/core/screenshot-testing/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/screenshot-testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/screenshot-testing/README.md b/core/screenshot-testing/README.md new file mode 100644 index 000000000..9bd4f1f9c --- /dev/null +++ b/core/screenshot-testing/README.md @@ -0,0 +1,3 @@ +# :core:screenshot-testing module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_screenshot_testing.svg) diff --git a/core/screenshot-testing/build.gradle.kts b/core/screenshot-testing/build.gradle.kts new file mode 100644 index 000000000..1b816fc67 --- /dev/null +++ b/core/screenshot-testing/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.hilt) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.core.screenshottesting" +} + +dependencies { + api(libs.bundles.androidx.compose.ui.test) + api(libs.roborazzi) + implementation(libs.androidx.compose.ui.test) + implementation(libs.androidx.activity.compose) + implementation(libs.robolectric) + implementation(projects.core.designsystem) +} diff --git a/core/screenshot-testing/src/main/AndroidManifest.xml b/core/screenshot-testing/src/main/AndroidManifest.xml new file mode 100644 index 000000000..51d0cfc2e --- /dev/null +++ b/core/screenshot-testing/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt b/core/screenshot-testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt similarity index 93% rename from core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt rename to core/screenshot-testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt index e84fe7d33..f11651220 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt +++ b/core/screenshot-testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt @@ -25,6 +25,8 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.DarkMode +import androidx.compose.ui.test.DeviceConfigurationOverride import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.onRoot import androidx.test.ext.junit.rules.ActivityScenarioRule @@ -32,14 +34,15 @@ import com.github.takahirom.roborazzi.RoborazziOptions import com.github.takahirom.roborazzi.RoborazziOptions.CompareOptions import com.github.takahirom.roborazzi.RoborazziOptions.RecordOptions import com.github.takahirom.roborazzi.captureRoboImage -import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import org.robolectric.RuntimeEnvironment val DefaultRoborazziOptions = RoborazziOptions( - compareOptions = CompareOptions(changeThreshold = 0f), // Pixel-perfect matching - recordOptions = RecordOptions(resizeScale = 0.5), // Reduce the size of the PNGs + // Pixel-perfect matching + compareOptions = CompareOptions(changeThreshold = 0f), + // Reduce the size of the PNGs + recordOptions = RecordOptions(resizeScale = 0.5), ) enum class DefaultTestDevices(val description: String, val spec: String) { @@ -51,7 +54,7 @@ fun AndroidComposeTestRule, A>.c screenshotName: String, body: @Composable () -> Unit, ) { - DefaultTestDevices.values().forEach { + DefaultTestDevices.entries.forEach { this.captureForDevice(it.description, it.spec, screenshotName, body = body) } } @@ -73,7 +76,9 @@ fun AndroidComposeTestRule, A>.c CompositionLocalProvider( LocalInspectionMode provides true, ) { - TestHarness(darkMode = darkMode) { + DeviceConfigurationOverride( + override = DeviceConfigurationOverride.Companion.DarkMode(darkMode), + ) { body() } } diff --git a/core/testing/README.md b/core/testing/README.md index 8eea64ac9..5a35d379b 100644 --- a/core/testing/README.md +++ b/core/testing/README.md @@ -1,3 +1,3 @@ # :core:testing module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_testing.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_testing.svg) diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 8ad91a0d5..90f2405a4 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -15,7 +15,6 @@ */ plugins { alias(libs.plugins.nowinandroid.android.library) - alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.hilt) } @@ -24,28 +23,15 @@ android { } dependencies { - api(libs.accompanist.testharness) - api(libs.androidx.activity.compose) - api(libs.androidx.compose.ui.test) - api(libs.androidx.test.core) - api(libs.androidx.test.espresso.core) - api(libs.androidx.test.rules) - api(libs.androidx.test.runner) - api(libs.hilt.android.testing) - api(libs.junit4) api(libs.kotlinx.coroutines.test) - api(libs.roborazzi) - api(libs.robolectric.shadows) - api(libs.turbine) + api(projects.core.analytics) + api(projects.core.common) + api(projects.core.data) + api(projects.core.model) + api(projects.core.notifications) - debugApi(libs.androidx.compose.ui.testManifest) - implementation(projects.core.common) - implementation(projects.core.data) - implementation(projects.core.designsystem) - implementation(projects.core.domain) - implementation(projects.core.model) - implementation(projects.core.notifications) - implementation(projects.core.analytics) + implementation(libs.androidx.test.rules) + implementation(libs.hilt.android.testing) implementation(libs.kotlinx.datetime) } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/NiaTestRunner.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/NiaTestRunner.kt index 30254a617..9b3b185df 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/NiaTestRunner.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/NiaTestRunner.kt @@ -25,7 +25,6 @@ import dagger.hilt.android.testing.HiltTestApplication * A custom runner to set up the instrumented application class for tests. */ class NiaTestRunner : AndroidJUnitRunner() { - override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) - } + override fun newApplication(cl: ClassLoader, name: String, context: Context): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt index a96326a62..9b85516e7 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt @@ -14,12 +14,13 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic -/* ktlint-disable max-line-length */ val followableTopicTestData: List = listOf( FollowableTopic( topic = Topic( diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt index ef845dc58..b3fff7ca0 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt @@ -14,12 +14,13 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import kotlinx.datetime.Instant -/* ktlint-disable max-line-length */ val newsResourcesTestData: List = listOf( NewsResource( id = "1", diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt index 90e9166a5..fc6ef62a6 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt @@ -14,11 +14,12 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.Topic -/* ktlint-disable max-line-length */ val topicsTestData: List = listOf( Topic( id = "2", diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt index 97acad088..4174391df 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -26,7 +28,6 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant -/* ktlint-disable max-line-length */ val userNewsResourcesTestData: List = UserData( bookmarkedNewsResources = setOf("1", "4"), viewedNewsResources = setOf("1", "2", "4"), diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatcherModule.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatcherModule.kt index 66d52dabe..09c739243 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatcherModule.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatcherModule.kt @@ -26,7 +26,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object TestDispatcherModule { +internal object TestDispatcherModule { @Provides @Singleton fun providesTestDispatcher(): TestDispatcher = UnconfinedTestDispatcher() diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt index f2134105a..4f5d15be1 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.test.TestDispatcher components = [SingletonComponent::class], replaces = [DispatchersModule::class], ) -object TestDispatchersModule { +internal object TestDispatchersModule { @Provides @Dispatcher(IO) fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt index d0bfd21a1..ef065a9f8 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt @@ -43,9 +43,7 @@ class TestNewsRepository : NewsRepository { } } query.filterNewsIds?.let { filterNewsIds -> - result = newsResources.filter { - filterNewsIds.contains(it.id) - } + result = newsResources.filter { it.id in filterNewsIds } } result } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt index 961473401..f700bdc31 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt @@ -32,7 +32,5 @@ class TestRecentSearchRepository : RecentSearchRepository { cachedRecentSearches.add(RecentSearchQuery(searchQuery)) } - override suspend fun clearRecentSearches() { - cachedRecentSearches.clear() - } + override suspend fun clearRecentSearches() = cachedRecentSearches.clear() } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt index 2aa54e463..5436cd10f 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt @@ -21,45 +21,36 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.SearchResult import com.google.samples.apps.nowinandroid.core.model.data.Topic import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import org.jetbrains.annotations.TestOnly class TestSearchContentsRepository : SearchContentsRepository { - private val cachedTopics: MutableList = mutableListOf() - private val cachedNewsResources: MutableList = mutableListOf() + private val cachedTopics = MutableStateFlow(emptyList()) + private val cachedNewsResources = MutableStateFlow(emptyList()) - override suspend fun populateFtsData() { /* no-op */ } + override suspend fun populateFtsData() = Unit - override fun searchContents(searchQuery: String): Flow = flowOf( - SearchResult( - topics = cachedTopics.filter { - it.name.contains(searchQuery) || - it.shortDescription.contains(searchQuery) || - it.longDescription.contains(searchQuery) - }, - newsResources = cachedNewsResources.filter { - it.content.contains(searchQuery) || - it.title.contains(searchQuery) - }, - ), - ) + override fun searchContents(searchQuery: String): Flow = + combine(cachedTopics, cachedNewsResources) { topics, news -> + SearchResult( + topics = topics.filter { + searchQuery in it.name || searchQuery in it.shortDescription || searchQuery in it.longDescription + }, + newsResources = news.filter { + searchQuery in it.content || searchQuery in it.title + }, + ) + } - override fun getSearchContentsCount(): Flow = flow { - emit(cachedTopics.size + cachedNewsResources.size) - } + override fun getSearchContentsCount(): Flow = combine(cachedTopics, cachedNewsResources) { topics, news -> topics.size + news.size } - /** - * Test only method to add the topics to the stored list in memory - */ - fun addTopics(topics: List) { - cachedTopics.addAll(topics) - } + @TestOnly + fun addTopics(topics: List) = cachedTopics.update { it + topics } - /** - * Test only method to add the news resources to the stored list in memory - */ - fun addNewsResources(newsResources: List) { - cachedNewsResources.addAll(newsResources) - } + @TestOnly + fun addNewsResources(newsResources: List) = + cachedNewsResources.update { it + newsResources } } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt index a95469d83..ddccbbe35 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt @@ -33,9 +33,8 @@ class TestTopicsRepository : TopicsRepository { override fun getTopics(): Flow> = topicsFlow - override fun getTopic(id: String): Flow { - return topicsFlow.map { topics -> topics.find { it.id == id }!! } - } + override fun getTopic(id: String): Flow = + topicsFlow.map { topics -> topics.find { it.id == id }!! } /** * A test-only API to allow controlling the list of topics from tests. diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt index 504e79217..be76112dc 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt @@ -61,7 +61,7 @@ class TestUserDataRepository : UserDataRepository { } } - override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { + override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) { currentUserData.let { current -> val bookmarkedNews = if (bookmarked) { current.bookmarkedNewsResources + newsResourceId diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt index 5b8807b75..666c4edd4 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt @@ -30,13 +30,9 @@ import org.junit.runner.Description * for the duration of the test. */ class MainDispatcherRule( - val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), ) : TestWatcher() { - override fun starting(description: Description) { - Dispatchers.setMain(testDispatcher) - } + override fun starting(description: Description) = Dispatchers.setMain(testDispatcher) - override fun finished(description: Description) { - Dispatchers.resetMain() - } + override fun finished(description: Description) = Dispatchers.resetMain() } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestAnalyticsHelper.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestAnalyticsHelper.kt index 005784c21..5f72d30e6 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestAnalyticsHelper.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestAnalyticsHelper.kt @@ -26,5 +26,5 @@ class TestAnalyticsHelper : AnalyticsHelper { events.add(event) } - fun hasLogged(event: AnalyticsEvent) = events.contains(event) + fun hasLogged(event: AnalyticsEvent) = event in events } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt index 999b67195..ff1e2fdd9 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt @@ -26,9 +26,7 @@ class TestSyncManager : SyncManager { override val isSyncing: Flow = syncStatusFlow - override fun requestSync() { - TODO("Not yet implemented") - } + override fun requestSync(): Unit = TODO("Not yet implemented") /** * A test-only API to set the sync status from tests. diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt new file mode 100644 index 000000000..cc71ab2ca --- /dev/null +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.util + +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.datetime.TimeZone + +class TestTimeZoneMonitor : TimeZoneMonitor { + + private val timeZoneFlow = MutableStateFlow(defaultTimeZone) + + override val currentTimeZone: Flow = timeZoneFlow + + /** + * A test-only API to set the from tests. + */ + fun setTimeZone(zoneId: TimeZone) { + timeZoneFlow.value = zoneId + } + + companion object { + val defaultTimeZone: TimeZone = TimeZone.of("Europe/Warsaw") + } +} diff --git a/core/ui/README.md b/core/ui/README.md index 88c3561f1..38e514d01 100644 --- a/core/ui/README.md +++ b/core/ui/README.md @@ -1,3 +1,3 @@ # :core:ui module - -![Dependency graph](../../docs/images/graphs/dep_graph_core_ui.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_ui.svg) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index c9527d09e..5606cb5d1 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -20,36 +20,19 @@ plugins { } android { - defaultConfig { - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } namespace = "com.google.samples.apps.nowinandroid.core.ui" } dependencies { - api(libs.androidx.compose.foundation) - api(libs.androidx.compose.foundation.layout) - api(libs.androidx.compose.material.iconsExtended) - api(libs.androidx.compose.material3) - api(libs.androidx.compose.runtime) - api(libs.androidx.compose.runtime.livedata) - api(libs.androidx.compose.ui.tooling.preview) - api(libs.androidx.compose.ui.util) api(libs.androidx.metrics) - api(libs.androidx.tracing.ktx) + api(projects.core.analytics) + api(projects.core.designsystem) + api(projects.core.model) - debugApi(libs.androidx.compose.ui.tooling) - - implementation(projects.core.analytics) - implementation(projects.core.designsystem) - implementation(projects.core.domain) - implementation(projects.core.model) - implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) - implementation(libs.androidx.core.ktx) implementation(libs.coil.kt) implementation(libs.coil.kt.compose) - implementation(libs.kotlinx.datetime) + androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(projects.core.testing) } diff --git a/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt b/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt index a2fdbaee1..d0a124316 100644 --- a/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt +++ b/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt @@ -52,7 +52,7 @@ class NewsResourceCardTest { composeTestRule .onNodeWithText( composeTestRule.activity.getString( - R.string.card_meta_data_text, + R.string.core_ui_card_meta_data_text, dateFormatted, newsWithKnownResourceType.type, ), @@ -123,7 +123,7 @@ class NewsResourceCardTest { composeTestRule .onNodeWithContentDescription( composeTestRule.activity.getString( - R.string.unread_resource_dot_content_description, + R.string.core_ui_unread_resource_dot_content_description, ), ) .assertIsDisplayed() @@ -147,7 +147,7 @@ class NewsResourceCardTest { composeTestRule .onNodeWithContentDescription( composeTestRule.activity.getString( - R.string.unread_resource_dot_content_description, + R.string.core_ui_unread_resource_dot_content_description, ), ) .assertDoesNotExist() diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt index 2132f5b16..16ae1eced 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt @@ -14,13 +14,14 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic -/* ktlint-disable max-line-length */ /** * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) * provides list of [FollowableTopic] for Composable previews. diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/InterestsItem.kt similarity index 81% rename from feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt rename to core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/InterestsItem.kt index 7456ba92b..28cd8d938 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/InterestsItem.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.interests +package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -37,7 +38,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicA import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.feature.interests.R.string +import com.google.samples.apps.nowinandroid.core.ui.R.string @Composable fun InterestsItem( @@ -49,6 +50,7 @@ fun InterestsItem( modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, description: String = "", + isSelected: Boolean = false, ) { ListItem( leadingContent = { @@ -68,7 +70,7 @@ fun InterestsItem( Icon( imageVector = NiaIcons.Add, contentDescription = stringResource( - id = string.card_follow_button_content_desc, + id = string.core_ui_interests_card_follow_button_content_desc, ), ) }, @@ -76,17 +78,23 @@ fun InterestsItem( Icon( imageVector = NiaIcons.Check, contentDescription = stringResource( - id = string.card_unfollow_button_content_desc, + id = string.core_ui_interests_card_unfollow_button_content_desc, ), ) }, ) }, colors = ListItemDefaults.colors( - containerColor = Color.Transparent, + containerColor = if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + }, ), modifier = modifier - .semantics(mergeDescendants = true) { /* no-op */ } + .semantics(mergeDescendants = true) { + selected = isSelected + } .clickable(enabled = true, onClick = onClick), ) } @@ -99,7 +107,8 @@ private fun InterestsIcon(topicImageUrl: String, modifier: Modifier = Modifier) .background(MaterialTheme.colorScheme.surface) .padding(4.dp), imageVector = NiaIcons.Person, - contentDescription = null, // decorative image + // decorative image + contentDescription = null, ) } else { DynamicAsyncImage( @@ -178,3 +187,21 @@ private fun InterestsCardWithEmptyDescriptionPreview() { } } } + +@Preview +@Composable +private fun InterestsCardSelectedPreview() { + NiaTheme { + Surface { + InterestsItem( + name = "Compose", + description = "", + following = true, + topicImageUrl = "", + onClick = { }, + onFollowButtonClick = { }, + isSelected = true, + ) + } + } +} diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt index c9fee1ac8..ef3de1059 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt @@ -50,7 +50,7 @@ fun rememberMetricsStateHolder(): Holder { */ @Composable fun TrackJank( - vararg keys: Any?, + vararg keys: Any, reportMetric: suspend CoroutineScope.(state: Holder) -> Unit, ) { val metrics = rememberMetricsStateHolder() @@ -65,7 +65,7 @@ fun TrackJank( */ @Composable fun TrackDisposableJank( - vararg keys: Any?, + vararg keys: Any, reportMetric: DisposableEffectScope.(state: Holder) -> DisposableEffectResult, ) { val metrics = rememberMetricsStateHolder() diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt new file mode 100644 index 000000000..2d9948488 --- /dev/null +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.ui + +import androidx.compose.runtime.compositionLocalOf +import kotlinx.datetime.TimeZone + +/** + * TimeZone that can be provided with the TimeZoneMonitor. + * This way, it's not needed to pass every single composable the time zone to show in UI. + */ +val LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() } diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index e2904afc3..afdb584a2 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -30,9 +30,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext @@ -64,9 +61,6 @@ fun LazyStaggeredGridScope.newsFeed( key = { it.id }, contentType = { "newsFeedItem" }, ) { userNewsResource -> - val resourceUrl by remember { - mutableStateOf(Uri.parse(userNewsResource.url)) - } val context = LocalContext.current val analyticsHelper = LocalAnalyticsHelper.current val backgroundColor = MaterialTheme.colorScheme.background.toArgb() @@ -79,7 +73,8 @@ fun LazyStaggeredGridScope.newsFeed( analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, ) - launchCustomChromeTab(context, resourceUrl, backgroundColor) + launchCustomChromeTab(context, Uri.parse(userNewsResource.url), backgroundColor) + onNewsResourceViewed(userNewsResource.id) }, hasBeenViewed = userNewsResource.hasBeenViewed, diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index de4aec9d7..e60c498eb 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -33,14 +33,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,7 +47,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -71,7 +68,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Instant import kotlinx.datetime.toJavaInstant -import java.time.ZoneId +import kotlinx.datetime.toJavaZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale @@ -80,7 +77,6 @@ import java.util.Locale * [NewsResource] card used on the following screens: For You, Saved */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewsResourceCardExpanded( userNewsResource: UserNewsResource, @@ -91,7 +87,7 @@ fun NewsResourceCardExpanded( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { - val clickActionLabel = stringResource(R.string.card_tap_action) + val clickActionLabel = stringResource(R.string.core_ui_card_tap_action) Card( onClick = onClick, shape = RoundedCornerShape(16.dp), @@ -121,7 +117,7 @@ fun NewsResourceCardExpanded( Spacer(modifier = Modifier.weight(1f)) BookmarkButton(isBookmarked, onToggleBookmark) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(14.dp)) Row(verticalAlignment = Alignment.CenterVertically) { if (!hasBeenViewed) { NotificationDot( @@ -132,7 +128,7 @@ fun NewsResourceCardExpanded( } NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(14.dp)) NewsResourceShortDescription(userNewsResource.content) Spacer(modifier = Modifier.height(12.dp)) NewsResourceTopics( @@ -183,10 +179,11 @@ fun NewsResourceHeaderImage( painter = if (isError.not() && !isLocalInspection) { imageLoader } else { - painterResource(drawable.ic_placeholder_default) + painterResource(drawable.core_designsystem_ic_placeholder_default) }, // TODO b/226661685: Investigate using alt text of image to populate content description - contentDescription = null, // decorative image, + // decorative image, + contentDescription = null, ) } } @@ -212,13 +209,13 @@ fun BookmarkButton( icon = { Icon( imageVector = NiaIcons.BookmarkBorder, - contentDescription = stringResource(R.string.bookmark), + contentDescription = stringResource(R.string.core_ui_bookmark), ) }, checkedIcon = { Icon( imageVector = NiaIcons.Bookmark, - contentDescription = stringResource(R.string.unbookmark), + contentDescription = stringResource(R.string.core_ui_unbookmark), ) }, ) @@ -229,7 +226,7 @@ fun NotificationDot( color: Color, modifier: Modifier = Modifier, ) { - val description = stringResource(R.string.unread_resource_dot_content_description) + val description = stringResource(R.string.core_ui_unread_resource_dot_content_description) Canvas( modifier = modifier .semantics { contentDescription = description }, @@ -243,27 +240,11 @@ fun NotificationDot( } @Composable -fun dateFormatted(publishDate: Instant): String { - var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) } - - val context = LocalContext.current - - DisposableEffect(context) { - val receiver = TimeZoneBroadcastReceiver( - onTimeZoneChanged = { zoneId = ZoneId.systemDefault() }, - ) - receiver.register(context) - onDispose { - receiver.unregister(context) - } - } - - return DateTimeFormatter - .ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(Locale.getDefault()) - .withZone(zoneId) - .format(publishDate.toJavaInstant()) -} +fun dateFormatted(publishDate: Instant): String = DateTimeFormatter + .ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + .withZone(LocalTimeZone.current.toJavaZoneId()) + .format(publishDate.toJavaInstant()) @Composable fun NewsResourceMetaData( @@ -273,7 +254,7 @@ fun NewsResourceMetaData( val formattedDate = dateFormatted(publishDate) Text( if (resourceType.isNotBlank()) { - stringResource(R.string.card_meta_data_text, formattedDate, resourceType) + stringResource(R.string.core_ui_card_meta_data_text, formattedDate, resourceType) } else { formattedDate }, @@ -295,7 +276,8 @@ fun NewsResourceTopics( modifier: Modifier = Modifier, ) { Row( - modifier = modifier.horizontalScroll(rememberScrollState()), // causes narrow chips + // causes narrow chips + modifier = modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { for (followableTopic in topics) { @@ -305,12 +287,12 @@ fun NewsResourceTopics( text = { val contentDescription = if (followableTopic.isFollowed) { stringResource( - R.string.topic_chip_content_description_when_followed, + R.string.core_ui_topic_chip_content_description_when_followed, followableTopic.topic.name, ) } else { stringResource( - R.string.topic_chip_content_description_when_not_followed, + R.string.core_ui_topic_chip_content_description_when_not_followed, followableTopic.topic.name, ) } diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/TimeZoneBroadcastReceiver.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/TimeZoneBroadcastReceiver.kt deleted file mode 100644 index f7ae813c4..000000000 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/TimeZoneBroadcastReceiver.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.core.ui - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter - -class TimeZoneBroadcastReceiver( - val onTimeZoneChanged: () -> Unit, -) : BroadcastReceiver() { - private var registered = false - - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) { - onTimeZoneChanged() - } - } - - fun register(context: Context) { - if (!registered) { - val filter = IntentFilter() - filter.addAction(Intent.ACTION_TIMEZONE_CHANGED) - context.registerReceiver(this, filter) - registered = true - } - } - - fun unregister(context: Context) { - if (registered) { - context.unregisterReceiver(this) - registered = false - } - } -} diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index 493788314..3189e8403 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -29,7 +31,6 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant -/* ktlint-disable max-line-length */ /** * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) * provides list of [UserNewsResource] for Composable previews. diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index d21a5ea36..ab76748ef 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -15,15 +15,18 @@ limitations under the License. --> - Bookmark - Unbookmark - Back + Bookmark + Unbookmark + Back - Unread + Unread - Open Resource Link - %1$s • %2$s + Open Resource Link + %1$s • %2$s - %1$s is followed - %1$s is not followed + %1$s is followed + %1$s is not followed + + Follow interest + Unfollow interest diff --git a/docs/ArchitectureLearningJourney.md b/docs/ArchitectureLearningJourney.md index 925858111..d98dadf1e 100644 --- a/docs/ArchitectureLearningJourney.md +++ b/docs/ArchitectureLearningJourney.md @@ -25,6 +25,8 @@ The app architecture has three layers: a [data layer](https://developer.android. Diagram showing overall app architecture +> [!NOTE] +> The official Android architecture is different from other architectures, such as "Clean Architecture". Concepts from other architectures may not apply here, or be applied in different ways. [More discussion here](https://github.com/android/nowinandroid/discussions/1273). The architecture follows a reactive programming model with [unidirectional data flow](https://developer.android.com/jetpack/guide/ui-layer#udf). With the data layer at the bottom, the key concepts are: diff --git a/docs/images/graphs/dep_graph_app.png b/docs/images/graphs/dep_graph_app.png deleted file mode 100644 index dc1e0b7d7..000000000 Binary files a/docs/images/graphs/dep_graph_app.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_app.svg b/docs/images/graphs/dep_graph_app.svg new file mode 100644 index 000000000..8e5d9d429 --- /dev/null +++ b/docs/images/graphs/dep_graph_app.svg @@ -0,0 +1,305 @@ + + + + + + :app + + + + :feature:interests + + + + + + + + :feature:foryou + + + + + + + + :feature:bookmarks + + + + + + + + :feature:topic + + + + + + + + :feature:search + + + + + + + + :feature:settings + + + + + + + + :core:common + + + + + + + + :core:ui + + + + + + + + :core:designsystem + + + + + + + + :core:data + + + + + + + + :core:model + + + + + + + + :core:analytics + + + + + + + + :sync:work + + + + + + + + + + + + + + + + + + + + :core:domain + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_app_nia_catalog.png b/docs/images/graphs/dep_graph_app_nia_catalog.png deleted file mode 100644 index e2698f0b0..000000000 Binary files a/docs/images/graphs/dep_graph_app_nia_catalog.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_app_nia_catalog.svg b/docs/images/graphs/dep_graph_app_nia_catalog.svg new file mode 100644 index 000000000..151ee63ad --- /dev/null +++ b/docs/images/graphs/dep_graph_app_nia_catalog.svg @@ -0,0 +1,45 @@ + + + + + + :app-nia-catalog + + + + :core:designsystem + + + + + + + + :core:ui + + + + + + + + + + + + :core:analytics + + + + + + + + :core:model + + + + + + + diff --git a/docs/images/graphs/dep_graph_benchmark.png b/docs/images/graphs/dep_graph_benchmark.png deleted file mode 100644 index a724c2fca..000000000 Binary files a/docs/images/graphs/dep_graph_benchmark.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_analytics.svg b/docs/images/graphs/dep_graph_core_analytics.svg new file mode 100644 index 000000000..45f1c1eb0 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_analytics.svg @@ -0,0 +1,9 @@ + + + + + + :core:analytics + + + diff --git a/docs/images/graphs/dep_graph_core_common.png b/docs/images/graphs/dep_graph_core_common.png deleted file mode 100644 index 8e5628068..000000000 Binary files a/docs/images/graphs/dep_graph_core_common.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_common.svg b/docs/images/graphs/dep_graph_core_common.svg new file mode 100644 index 000000000..91033eaa0 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_common.svg @@ -0,0 +1,9 @@ + + + + + + :core:common + + + diff --git a/docs/images/graphs/dep_graph_core_data.png b/docs/images/graphs/dep_graph_core_data.png deleted file mode 100644 index fc30029b7..000000000 Binary files a/docs/images/graphs/dep_graph_core_data.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_data.svg b/docs/images/graphs/dep_graph_core_data.svg new file mode 100644 index 000000000..ab91bafb2 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_data.svg @@ -0,0 +1,97 @@ + + + + + + :core:data + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:analytics + + + + + + + + :core:notifications + + + + + + + + :core:model + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_data_test.png b/docs/images/graphs/dep_graph_core_data_test.png deleted file mode 100644 index c3762b600..000000000 Binary files a/docs/images/graphs/dep_graph_core_data_test.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_data_test.svg b/docs/images/graphs/dep_graph_core_data_test.svg new file mode 100644 index 000000000..b9736c859 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_data_test.svg @@ -0,0 +1,105 @@ + + + + + + :core:data-test + + + + :core:data + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:analytics + + + + + + + + :core:notifications + + + + + + + + :core:model + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_database.png b/docs/images/graphs/dep_graph_core_database.png deleted file mode 100644 index dc3e65756..000000000 Binary files a/docs/images/graphs/dep_graph_core_database.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_database.svg b/docs/images/graphs/dep_graph_core_database.svg new file mode 100644 index 000000000..e82d46436 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_database.svg @@ -0,0 +1,17 @@ + + + + + + :core:database + + + + :core:model + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_datastore.png b/docs/images/graphs/dep_graph_core_datastore.png deleted file mode 100644 index 861c2498a..000000000 Binary files a/docs/images/graphs/dep_graph_core_datastore.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_datastore.svg b/docs/images/graphs/dep_graph_core_datastore.svg new file mode 100644 index 000000000..f7502e55b --- /dev/null +++ b/docs/images/graphs/dep_graph_core_datastore.svg @@ -0,0 +1,33 @@ + + + + + + :core:datastore + + + + :core:datastore-proto + + + + + + + + :core:model + + + + + + + + :core:common + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_datastore_proto.svg b/docs/images/graphs/dep_graph_core_datastore_proto.svg new file mode 100644 index 000000000..7fcfb8358 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_datastore_proto.svg @@ -0,0 +1,9 @@ + + + + + + :core:datastore-proto + + + diff --git a/docs/images/graphs/dep_graph_core_datastore_test.png b/docs/images/graphs/dep_graph_core_datastore_test.png deleted file mode 100644 index efe51c7de..000000000 Binary files a/docs/images/graphs/dep_graph_core_datastore_test.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_datastore_test.svg b/docs/images/graphs/dep_graph_core_datastore_test.svg new file mode 100644 index 000000000..37521a05f --- /dev/null +++ b/docs/images/graphs/dep_graph_core_datastore_test.svg @@ -0,0 +1,45 @@ + + + + + + :core:datastore-test + + + + :core:common + + + + + + + + :core:datastore + + + + + + + + + + + + :core:datastore-proto + + + + + + + + :core:model + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_designsystem.png b/docs/images/graphs/dep_graph_core_designsystem.png deleted file mode 100644 index 1d6002d2a..000000000 Binary files a/docs/images/graphs/dep_graph_core_designsystem.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_designsystem.svg b/docs/images/graphs/dep_graph_core_designsystem.svg new file mode 100644 index 000000000..737140876 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_designsystem.svg @@ -0,0 +1,9 @@ + + + + + + :core:designsystem + + + diff --git a/docs/images/graphs/dep_graph_core_domain.svg b/docs/images/graphs/dep_graph_core_domain.svg new file mode 100644 index 000000000..fe3740d2f --- /dev/null +++ b/docs/images/graphs/dep_graph_core_domain.svg @@ -0,0 +1,109 @@ + + + + + + :core:domain + + + + :core:data + + + + + + + + :core:model + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:analytics + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_model.png b/docs/images/graphs/dep_graph_core_model.png deleted file mode 100644 index 205583afa..000000000 Binary files a/docs/images/graphs/dep_graph_core_model.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_model.svg b/docs/images/graphs/dep_graph_core_model.svg new file mode 100644 index 000000000..125684a08 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_model.svg @@ -0,0 +1,9 @@ + + + + + + :core:model + + + diff --git a/docs/images/graphs/dep_graph_core_navigation.png b/docs/images/graphs/dep_graph_core_navigation.png deleted file mode 100644 index 5ceab49b8..000000000 Binary files a/docs/images/graphs/dep_graph_core_navigation.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_network.png b/docs/images/graphs/dep_graph_core_network.png deleted file mode 100644 index 908715660..000000000 Binary files a/docs/images/graphs/dep_graph_core_network.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_network.svg b/docs/images/graphs/dep_graph_core_network.svg new file mode 100644 index 000000000..3022a86ee --- /dev/null +++ b/docs/images/graphs/dep_graph_core_network.svg @@ -0,0 +1,25 @@ + + + + + + :core:network + + + + :core:common + + + + + + + + :core:model + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_notifications.svg b/docs/images/graphs/dep_graph_core_notifications.svg new file mode 100644 index 000000000..d96d28769 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_notifications.svg @@ -0,0 +1,25 @@ + + + + + + :core:notifications + + + + :core:model + + + + + + + + :core:common + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_screenshot_testing.svg b/docs/images/graphs/dep_graph_core_screenshot_testing.svg new file mode 100644 index 000000000..a7d58b0ea --- /dev/null +++ b/docs/images/graphs/dep_graph_core_screenshot_testing.svg @@ -0,0 +1,17 @@ + + + + + + :core:screenshot-testing + + + + :core:designsystem + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_testing.png b/docs/images/graphs/dep_graph_core_testing.png deleted file mode 100644 index 162830ae7..000000000 Binary files a/docs/images/graphs/dep_graph_core_testing.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_testing.svg b/docs/images/graphs/dep_graph_core_testing.svg new file mode 100644 index 000000000..d441858e5 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_testing.svg @@ -0,0 +1,121 @@ + + + + + + :core:testing + + + + :core:analytics + + + + + + + + :core:common + + + + + + + + :core:data + + + + + + + + :core:model + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_core_ui.png b/docs/images/graphs/dep_graph_core_ui.png deleted file mode 100644 index 31c9e6715..000000000 Binary files a/docs/images/graphs/dep_graph_core_ui.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_core_ui.svg b/docs/images/graphs/dep_graph_core_ui.svg new file mode 100644 index 000000000..2cd972357 --- /dev/null +++ b/docs/images/graphs/dep_graph_core_ui.svg @@ -0,0 +1,33 @@ + + + + + + :core:ui + + + + :core:analytics + + + + + + + + :core:designsystem + + + + + + + + :core:model + + + + + + + diff --git a/docs/images/graphs/dep_graph_feature_author.png b/docs/images/graphs/dep_graph_feature_author.png deleted file mode 100644 index ddd1f03f6..000000000 Binary files a/docs/images/graphs/dep_graph_feature_author.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_feature_bookmarks.png b/docs/images/graphs/dep_graph_feature_bookmarks.png deleted file mode 100644 index f07fe891e..000000000 Binary files a/docs/images/graphs/dep_graph_feature_bookmarks.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_feature_bookmarks.svg b/docs/images/graphs/dep_graph_feature_bookmarks.svg new file mode 100644 index 000000000..0391eb39c --- /dev/null +++ b/docs/images/graphs/dep_graph_feature_bookmarks.svg @@ -0,0 +1,133 @@ + + + + + + :feature:bookmarks + + + + :core:ui + + + + + + + + :core:designsystem + + + + + + + + :core:data + + + + + + + + + + + + :core:analytics + + + + + + + + :core:model + + + + + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_feature_foryou.png b/docs/images/graphs/dep_graph_feature_foryou.png deleted file mode 100644 index cf483f1b1..000000000 Binary files a/docs/images/graphs/dep_graph_feature_foryou.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_feature_foryou.svg b/docs/images/graphs/dep_graph_feature_foryou.svg new file mode 100644 index 000000000..63a154a87 --- /dev/null +++ b/docs/images/graphs/dep_graph_feature_foryou.svg @@ -0,0 +1,149 @@ + + + + + + :feature:foryou + + + + :core:ui + + + + + + + + :core:designsystem + + + + + + + + :core:data + + + + + + + + :core:domain + + + + + + + + + + + + :core:analytics + + + + + + + + :core:model + + + + + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_feature_interests.png b/docs/images/graphs/dep_graph_feature_interests.png deleted file mode 100644 index 09c74f995..000000000 Binary files a/docs/images/graphs/dep_graph_feature_interests.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_feature_interests.svg b/docs/images/graphs/dep_graph_feature_interests.svg new file mode 100644 index 000000000..2de1fc61f --- /dev/null +++ b/docs/images/graphs/dep_graph_feature_interests.svg @@ -0,0 +1,149 @@ + + + + + + :feature:interests + + + + :core:ui + + + + + + + + :core:designsystem + + + + + + + + :core:data + + + + + + + + :core:domain + + + + + + + + + + + + :core:analytics + + + + + + + + :core:model + + + + + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_feature_search.svg b/docs/images/graphs/dep_graph_feature_search.svg new file mode 100644 index 000000000..7f8f29777 --- /dev/null +++ b/docs/images/graphs/dep_graph_feature_search.svg @@ -0,0 +1,149 @@ + + + + + + :feature:search + + + + :core:ui + + + + + + + + :core:designsystem + + + + + + + + :core:data + + + + + + + + :core:domain + + + + + + + + + + + + :core:analytics + + + + + + + + :core:model + + + + + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_feature_settings.svg b/docs/images/graphs/dep_graph_feature_settings.svg new file mode 100644 index 000000000..3f0d35df2 --- /dev/null +++ b/docs/images/graphs/dep_graph_feature_settings.svg @@ -0,0 +1,133 @@ + + + + + + :feature:settings + + + + :core:ui + + + + + + + + :core:designsystem + + + + + + + + :core:data + + + + + + + + + + + + :core:analytics + + + + + + + + :core:model + + + + + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_feature_topic.png b/docs/images/graphs/dep_graph_feature_topic.png deleted file mode 100644 index 8385d1ed6..000000000 Binary files a/docs/images/graphs/dep_graph_feature_topic.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_feature_topic.svg b/docs/images/graphs/dep_graph_feature_topic.svg new file mode 100644 index 000000000..b7c7dd26c --- /dev/null +++ b/docs/images/graphs/dep_graph_feature_topic.svg @@ -0,0 +1,133 @@ + + + + + + :feature:topic + + + + :core:ui + + + + + + + + :core:designsystem + + + + + + + + :core:data + + + + + + + + + + + + :core:analytics + + + + + + + + :core:model + + + + + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_lint.png b/docs/images/graphs/dep_graph_lint.png deleted file mode 100644 index 176d1de5d..000000000 Binary files a/docs/images/graphs/dep_graph_lint.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_sync.png b/docs/images/graphs/dep_graph_sync.png deleted file mode 100644 index 26b79b9bc..000000000 Binary files a/docs/images/graphs/dep_graph_sync.png and /dev/null differ diff --git a/docs/images/graphs/dep_graph_sync_sync_test.svg b/docs/images/graphs/dep_graph_sync_sync_test.svg new file mode 100644 index 000000000..7a083ba54 --- /dev/null +++ b/docs/images/graphs/dep_graph_sync_sync_test.svg @@ -0,0 +1,121 @@ + + + + + + :sync:sync-test + + + + :core:data + + + + + + + + :sync:work + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:analytics + + + + + + + + :core:notifications + + + + + + + + + + + + + + + + :core:model + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/graphs/dep_graph_sync_work.svg b/docs/images/graphs/dep_graph_sync_work.svg new file mode 100644 index 000000000..c649f2397 --- /dev/null +++ b/docs/images/graphs/dep_graph_sync_work.svg @@ -0,0 +1,109 @@ + + + + + + :sync:work + + + + :core:analytics + + + + + + + + :core:data + + + + + + + + + + + + :core:common + + + + + + + + :core:database + + + + + + + + :core:datastore + + + + + + + + :core:network + + + + + + + + :core:notifications + + + + + + + + :core:model + + + + + + + + + + + + + + + + :core:datastore-proto + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/bookmarks/README.md b/feature/bookmarks/README.md index e2b8c65ac..54cbf91d0 100644 --- a/feature/bookmarks/README.md +++ b/feature/bookmarks/README.md @@ -1,3 +1,3 @@ # :feature:bookmarks module - -![Dependency graph](../../docs/images/graphs/dep_graph_feature_bookmarks.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_feature_bookmarks.svg) diff --git a/feature/bookmarks/build.gradle.kts b/feature/bookmarks/build.gradle.kts index 32394f911..51a15ce7a 100644 --- a/feature/bookmarks/build.gradle.kts +++ b/feature/bookmarks/build.gradle.kts @@ -25,5 +25,10 @@ android { } dependencies { - implementation(libs.androidx.compose.material3.windowSizeClass) + implementation(projects.core.data) + + testImplementation(projects.core.testing) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) + androidTestImplementation(projects.core.testing) } diff --git a/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt index 6e432f2ab..40f54e4a7 100644 --- a/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -17,6 +17,8 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.filter @@ -30,8 +32,11 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.testing.TestLifecycleOwner import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals @@ -59,7 +64,7 @@ class BookmarksScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.saved_loading), + composeTestRule.activity.resources.getString(R.string.feature_bookmarks_loading), ) .assertExists() } @@ -125,7 +130,7 @@ class BookmarksScreenTest { composeTestRule .onAllNodesWithContentDescription( composeTestRule.activity.getString( - com.google.samples.apps.nowinandroid.core.ui.R.string.unbookmark, + com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_unbookmark, ), ).filter( hasAnyAncestor( @@ -156,14 +161,39 @@ class BookmarksScreenTest { composeTestRule .onNodeWithText( - composeTestRule.activity.getString(R.string.bookmarks_empty_error), + composeTestRule.activity.getString(R.string.feature_bookmarks_empty_error), ) .assertExists() composeTestRule .onNodeWithText( - composeTestRule.activity.getString(R.string.bookmarks_empty_description), + composeTestRule.activity.getString(R.string.feature_bookmarks_empty_description), ) .assertExists() } + + @Test + fun feed_whenLifecycleStops_undoBookmarkedStateIsCleared() = runTest { + var undoStateCleared = false + val testLifecycleOwner = TestLifecycleOwner(initialState = Lifecycle.State.STARTED) + + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides testLifecycleOwner) { + BookmarksScreen( + feedState = NewsFeedUiState.Success(emptyList()), + onShowSnackbar = { _, _ -> false }, + removeFromBookmarks = {}, + onTopicClick = {}, + onNewsResourceViewed = {}, + clearUndoState = { + undoStateCleared = true + }, + ) + } + } + + assertEquals(false, undoStateCleared) + testLifecycleOwner.handleLifecycleEvent(event = Lifecycle.Event.ON_STOP) + assertEquals(true, undoStateCleared) + } } diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 7d51c6e84..7c229c5ea 100644 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -42,13 +42,12 @@ import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridS import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -59,7 +58,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar @@ -113,8 +112,8 @@ internal fun BookmarksScreen( undoBookmarkRemoval: () -> Unit = {}, clearUndoState: () -> Unit = {}, ) { - val bookmarkRemovedMessage = stringResource(id = R.string.bookmark_removed) - val undoText = stringResource(id = R.string.undo) + val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_removed) + val undoText = stringResource(id = R.string.feature_bookmarks_undo) LaunchedEffect(shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) { @@ -127,15 +126,8 @@ internal fun BookmarksScreen( } } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_STOP) { - clearUndoState() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + clearUndoState() } when (feedState) { @@ -163,7 +155,7 @@ private fun LoadingState(modifier: Modifier = Modifier) { .fillMaxWidth() .wrapContentSize() .testTag("forYou:loading"), - contentDesc = stringResource(id = R.string.saved_loading), + contentDesc = stringResource(id = R.string.feature_bookmarks_loading), ) } @@ -236,15 +228,15 @@ private fun EmptyState(modifier: Modifier = Modifier) { val iconTint = LocalTintTheme.current.iconTint Image( modifier = Modifier.fillMaxWidth(), - painter = painterResource(id = R.drawable.img_empty_bookmarks), - colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null, + painter = painterResource(id = R.drawable.feature_bookmarks_img_empty_bookmarks), + colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null, contentDescription = null, ) Spacer(modifier = Modifier.height(48.dp)) Text( - text = stringResource(id = R.string.bookmarks_empty_error), + text = stringResource(id = R.string.feature_bookmarks_empty_error), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium, @@ -254,7 +246,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(id = R.string.bookmarks_empty_description), + text = stringResource(id = R.string.feature_bookmarks_empty_description), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index 7b6cac76a..f93602485 100644 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -58,7 +58,7 @@ class BookmarksViewModel @Inject constructor( viewModelScope.launch { shouldDisplayUndoBookmark = true lastRemovedBookmarkId = newsResourceId - userDataRepository.updateNewsResourceBookmark(newsResourceId, false) + userDataRepository.setNewsResourceBookmarked(newsResourceId, false) } } @@ -71,7 +71,7 @@ class BookmarksViewModel @Inject constructor( fun undoBookmarkRemoval() { viewModelScope.launch { lastRemovedBookmarkId?.let { - userDataRepository.updateNewsResourceBookmark(it, true) + userDataRepository.setNewsResourceBookmarked(it, true) } } clearUndoState() diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt index ebcde4ab1..13d0baef0 100644 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt +++ b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt @@ -22,17 +22,15 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute -const val bookmarksRoute = "bookmarks_route" +const val BOOKMARKS_ROUTE = "bookmarks_route" -fun NavController.navigateToBookmarks(navOptions: NavOptions? = null) { - this.navigate(bookmarksRoute, navOptions) -} +fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(BOOKMARKS_ROUTE, navOptions) fun NavGraphBuilder.bookmarksScreen( onTopicClick: (String) -> Unit, onShowSnackbar: suspend (String, String?) -> Boolean, ) { - composable(route = bookmarksRoute) { + composable(route = BOOKMARKS_ROUTE) { BookmarksRoute(onTopicClick, onShowSnackbar) } } diff --git a/feature/bookmarks/src/main/res/drawable/img_empty_bookmarks.xml b/feature/bookmarks/src/main/res/drawable/feature_bookmarks_img_empty_bookmarks.xml similarity index 100% rename from feature/bookmarks/src/main/res/drawable/img_empty_bookmarks.xml rename to feature/bookmarks/src/main/res/drawable/feature_bookmarks_img_empty_bookmarks.xml diff --git a/feature/bookmarks/src/main/res/values/strings.xml b/feature/bookmarks/src/main/res/values/strings.xml index 875a90a0b..6e2b23043 100644 --- a/feature/bookmarks/src/main/res/values/strings.xml +++ b/feature/bookmarks/src/main/res/values/strings.xml @@ -15,10 +15,10 @@ limitations under the License. --> - Saved - Loading saved… - No saved updates - Updates you save will be stored here\nto read later - Bookmark removed - UNDO + Saved + Loading saved… + No saved updates + Updates you save will be stored here\nto read later + Bookmark removed + UNDO diff --git a/feature/bookmarks/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt b/feature/bookmarks/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt index 6469a684b..037e9db64 100644 --- a/feature/bookmarks/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt +++ b/feature/bookmarks/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt @@ -67,7 +67,7 @@ class BookmarksViewModelTest { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() } newsRepository.sendNewsResources(newsResourcesTestData) - userDataRepository.updateNewsResourceBookmark(newsResourcesTestData[0].id, true) + userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true) val item = viewModel.feedUiState.value assertIs(item) assertEquals(item.feed.size, 1) @@ -81,7 +81,7 @@ class BookmarksViewModelTest { // Set the news resources to be used by this test newsRepository.sendNewsResources(newsResourcesTestData) // Start with the resource saved - userDataRepository.updateNewsResourceBookmark(newsResourcesTestData[0].id, true) + userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true) // Use viewModel to remove saved resource viewModel.removeFromSavedResources(newsResourcesTestData[0].id) // Verify list of saved resources is now empty diff --git a/feature/foryou/README.md b/feature/foryou/README.md index 1ca599859..0f08cb827 100644 --- a/feature/foryou/README.md +++ b/feature/foryou/README.md @@ -1,3 +1,3 @@ # :feature:foryou module - -![Dependency graph](../../docs/images/graphs/dep_graph_feature_foryou.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_feature_foryou.svg) diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index afe769ab8..004fe8ad6 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.jacoco) + alias(libs.plugins.roborazzi) } android { @@ -25,7 +26,15 @@ android { } dependencies { - implementation(libs.kotlinx.datetime) - implementation(libs.androidx.activity.compose) implementation(libs.accompanist.permissions) + implementation(projects.core.data) + implementation(projects.core.domain) + + testImplementation(libs.hilt.android.testing) + testImplementation(libs.robolectric) + testImplementation(projects.core.testing) + testDemoImplementation(projects.core.screenshotTesting) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) + androidTestImplementation(projects.core.testing) } diff --git a/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index 7431555ba..5477493ef 100644 --- a/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -45,7 +45,7 @@ class ForYouScreenTest { private val doneButtonMatcher by lazy { hasText( - composeTestRule.activity.resources.getString(R.string.done), + composeTestRule.activity.resources.getString(R.string.feature_foryou_done), ) } @@ -70,7 +70,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } @@ -96,7 +96,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } @@ -215,7 +215,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } @@ -241,7 +241,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 65b5ecbc4..885020636 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -81,9 +80,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.trace import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tracing.trace import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus.Denied import com.google.accompanist.permissions.rememberPermissionState @@ -217,7 +216,7 @@ internal fun ForYouScreen( targetOffsetY = { fullHeight -> -fullHeight }, ) + fadeOut(), ) { - val loadingContentDescription = stringResource(id = R.string.for_you_loading) + val loadingContentDescription = stringResource(id = R.string.feature_foryou_loading) Box( modifier = Modifier .fillMaxWidth() @@ -272,7 +271,7 @@ private fun LazyStaggeredGridScope.onboarding( item(span = StaggeredGridItemSpan.FullLine, contentType = "onboarding") { Column(modifier = interestsItemModifier) { Text( - text = stringResource(R.string.onboarding_guidance_title), + text = stringResource(R.string.feature_foryou_onboarding_guidance_title), textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() @@ -280,7 +279,7 @@ private fun LazyStaggeredGridScope.onboarding( style = MaterialTheme.typography.titleMedium, ) Text( - text = stringResource(R.string.onboarding_guidance_subtitle), + text = stringResource(R.string.feature_foryou_onboarding_guidance_subtitle), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp, start = 24.dp, end = 24.dp), @@ -306,7 +305,7 @@ private fun LazyStaggeredGridScope.onboarding( .fillMaxWidth(), ) { Text( - text = stringResource(R.string.done), + text = stringResource(R.string.feature_foryou_done), ) } } @@ -435,9 +434,10 @@ fun TopicIcon( modifier: Modifier = Modifier, ) { DynamicAsyncImage( - placeholder = painterResource(R.drawable.ic_icon_placeholder), + placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder), imageUrl = imageUrl, - contentDescription = null, // decorative + // decorative + contentDescription = null, modifier = modifier .padding(10.dp) .size(32.dp), @@ -507,23 +507,21 @@ fun ForYouScreenPopulatedFeed( @PreviewParameter(UserNewsResourcePreviewParameterProvider::class) userNewsResources: List, ) { - BoxWithConstraints { - NiaTheme { - ForYouScreen( - isSyncing = false, - onboardingUiState = OnboardingUiState.NotShown, - feedState = NewsFeedUiState.Success( - feed = userNewsResources, - ), - deepLinkedUserNewsResource = null, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourceViewed = {}, - onTopicClick = {}, - onDeepLinkOpened = {}, - ) - } + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = OnboardingUiState.NotShown, + feedState = NewsFeedUiState.Success( + feed = userNewsResources, + ), + deepLinkedUserNewsResource = null, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + onDeepLinkOpened = {}, + ) } } @@ -533,23 +531,21 @@ fun ForYouScreenOfflinePopulatedFeed( @PreviewParameter(UserNewsResourcePreviewParameterProvider::class) userNewsResources: List, ) { - BoxWithConstraints { - NiaTheme { - ForYouScreen( - isSyncing = false, - onboardingUiState = OnboardingUiState.NotShown, - feedState = NewsFeedUiState.Success( - feed = userNewsResources, - ), - deepLinkedUserNewsResource = null, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourceViewed = {}, - onTopicClick = {}, - onDeepLinkOpened = {}, - ) - } + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = OnboardingUiState.NotShown, + feedState = NewsFeedUiState.Success( + feed = userNewsResources, + ), + deepLinkedUserNewsResource = null, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + onDeepLinkOpened = {}, + ) } } @@ -559,47 +555,43 @@ fun ForYouScreenTopicSelection( @PreviewParameter(UserNewsResourcePreviewParameterProvider::class) userNewsResources: List, ) { - BoxWithConstraints { - NiaTheme { - ForYouScreen( - isSyncing = false, - onboardingUiState = OnboardingUiState.Shown( - topics = userNewsResources.flatMap { news -> news.followableTopics } - .distinctBy { it.topic.id }, - ), - feedState = NewsFeedUiState.Success( - feed = userNewsResources, - ), - deepLinkedUserNewsResource = null, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourceViewed = {}, - onTopicClick = {}, - onDeepLinkOpened = {}, - ) - } + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = OnboardingUiState.Shown( + topics = userNewsResources.flatMap { news -> news.followableTopics } + .distinctBy { it.topic.id }, + ), + feedState = NewsFeedUiState.Success( + feed = userNewsResources, + ), + deepLinkedUserNewsResource = null, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + onDeepLinkOpened = {}, + ) } } @DevicePreviews @Composable fun ForYouScreenLoading() { - BoxWithConstraints { - NiaTheme { - ForYouScreen( - isSyncing = false, - onboardingUiState = OnboardingUiState.Loading, - feedState = NewsFeedUiState.Loading, - deepLinkedUserNewsResource = null, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourceViewed = {}, - onTopicClick = {}, - onDeepLinkOpened = {}, - ) - } + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = OnboardingUiState.Loading, + feedState = NewsFeedUiState.Loading, + deepLinkedUserNewsResource = null, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + onDeepLinkOpened = {}, + ) } } @@ -609,22 +601,20 @@ fun ForYouScreenPopulatedAndLoading( @PreviewParameter(UserNewsResourcePreviewParameterProvider::class) userNewsResources: List, ) { - BoxWithConstraints { - NiaTheme { - ForYouScreen( - isSyncing = true, - onboardingUiState = OnboardingUiState.Loading, - feedState = NewsFeedUiState.Success( - feed = userNewsResources, - ), - deepLinkedUserNewsResource = null, - onTopicCheckedChanged = { _, _ -> }, - saveFollowedTopics = {}, - onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourceViewed = {}, - onTopicClick = {}, - onDeepLinkOpened = {}, - ) - } + NiaTheme { + ForYouScreen( + isSyncing = true, + onboardingUiState = OnboardingUiState.Loading, + feedState = NewsFeedUiState.Success( + feed = userNewsResources, + ), + deepLinkedUserNewsResource = null, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + onDeepLinkOpened = {}, + ) } } diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index 2a4b6f4ec..85035a77a 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -117,7 +117,7 @@ class ForYouViewModel @Inject constructor( fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) { viewModelScope.launch { - userDataRepository.updateNewsResourceBookmark(newsResourceId, isChecked) + userDataRepository.setNewsResourceBookmarked(newsResourceId, isChecked) } } diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt index 705495cc2..8e94a491a 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt +++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt @@ -26,17 +26,15 @@ import androidx.navigation.navDeepLink import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute const val LINKED_NEWS_RESOURCE_ID = "linkedNewsResourceId" -const val forYouNavigationRoute = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}" +const val FOR_YOU_ROUTE = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}" private const val DEEP_LINK_URI_PATTERN = "https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}" -fun NavController.navigateToForYou(navOptions: NavOptions? = null) { - this.navigate(forYouNavigationRoute, navOptions) -} +fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(FOR_YOU_ROUTE, navOptions) fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) { composable( - route = forYouNavigationRoute, + route = FOR_YOU_ROUTE, deepLinks = listOf( navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN }, ), diff --git a/feature/foryou/src/main/res/drawable/ic_icon_placeholder.xml b/feature/foryou/src/main/res/drawable/feature_foryou_ic_icon_placeholder.xml similarity index 100% rename from feature/foryou/src/main/res/drawable/ic_icon_placeholder.xml rename to feature/foryou/src/main/res/drawable/feature_foryou_ic_icon_placeholder.xml diff --git a/feature/foryou/src/main/res/values/strings.xml b/feature/foryou/src/main/res/values/strings.xml index 5a33bc9c8..166749664 100644 --- a/feature/foryou/src/main/res/values/strings.xml +++ b/feature/foryou/src/main/res/values/strings.xml @@ -15,11 +15,11 @@ limitations under the License. --> - For you - Done - Loading for you… - Navigate up - What are you interested in? - Updates from topics you follow will appear here. Follow some things to get started. + For you + Done + Loading for you… + Navigate up + What are you interested in? + Updates from topics you follow will appear here. Follow some things to get started. diff --git a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt index 1c521d419..14b67c64e 100644 --- a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt +++ b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt @@ -46,7 +46,7 @@ import java.util.TimeZone */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33]) +@Config(application = HiltTestApplication::class) @LooperMode(LooperMode.Mode.PAUSED) class ForYouScreenScreenshotTests { diff --git a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index b75573975..2fbdf0a79 100644 --- a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -36,6 +36,7 @@ import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -70,6 +71,7 @@ class ForYouViewModelTest { topicsRepository = topicsRepository, userDataRepository = userDataRepository, ) + private val savedStateHandle = SavedStateHandle() private lateinit var viewModel: ForYouViewModel @@ -504,6 +506,24 @@ class ForYouViewModelTest { collectJob.cancel() } + + @Test + fun whenUpdateNewsResourceSavedIsCalled_bookmarkStateIsUpdated() = runTest { + val newsResourceId = "123" + viewModel.updateNewsResourceSaved(newsResourceId, true) + + assertEquals( + expected = setOf(newsResourceId), + actual = userDataRepository.userData.first().bookmarkedNewsResources, + ) + + viewModel.updateNewsResourceSaved(newsResourceId, false) + + assertEquals( + expected = emptySet(), + actual = userDataRepository.userData.first().bookmarkedNewsResources, + ) + } } private val sampleTopics = listOf( diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png index 92d2978e0..f362c445d 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png index 0e6aedd53..8d02e5985 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png index 88b6ce240..e6f6a527a 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png index deb0cd855..f5ca39c3a 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png index 579bc98a8..7a3f99d7c 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png index f013bb40a..3a14048b5 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png index 75d6bc066..97458f73b 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png index a12c429d9..0b539aeca 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png index 0c36a8913..b19c8d708 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png index 021958401..bdf44b2a3 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png index 715889be5..b095c1a7a 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png index 0bbe04955..140fa8d6d 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png index 1ba8943ef..5d90732a0 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png index 9a51764c5..3dd62e765 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png differ diff --git a/feature/interests/README.md b/feature/interests/README.md index b7601ecbc..90a4fbc9c 100644 --- a/feature/interests/README.md +++ b/feature/interests/README.md @@ -1,3 +1,3 @@ # :feature:interests module - -![Dependency graph](../../docs/images/graphs/dep_graph_feature_interests.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_feature_interests.svg) diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts index 20b1ef1aa..ca91ba2c4 100644 --- a/feature/interests/build.gradle.kts +++ b/feature/interests/build.gradle.kts @@ -22,3 +22,13 @@ plugins { android { namespace = "com.google.samples.apps.nowinandroid.feature.interests" } + +dependencies { + implementation(projects.core.data) + implementation(projects.core.domain) + + testImplementation(projects.core.testing) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) + androidTestImplementation(projects.core.testing) +} diff --git a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt b/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt index 492e91fa3..a441f5a9d 100644 --- a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt +++ b/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.test.onNodeWithText import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState -import com.google.samples.apps.nowinandroid.feature.interests.R import org.junit.Before import org.junit.Rule import org.junit.Test +import com.google.samples.apps.nowinandroid.core.ui.R as CoreUiR +import com.google.samples.apps.nowinandroid.feature.interests.R as InterestsR /** * UI test for checking the correct behaviour of the Interests screen; @@ -50,12 +51,12 @@ class InterestsScreenTest { @Before fun setup() { composeTestRule.activity.apply { - interestsLoading = getString(R.string.loading) - interestsEmptyHeader = getString(R.string.empty_header) + interestsLoading = getString(InterestsR.string.feature_interests_loading) + interestsEmptyHeader = getString(InterestsR.string.feature_interests_empty_header) interestsTopicCardFollowButton = - getString(R.string.card_follow_button_content_desc) + getString(CoreUiR.string.core_ui_interests_card_follow_button_content_desc) interestsTopicCardUnfollowButton = - getString(R.string.card_unfollow_button_content_desc) + getString(CoreUiR.string.core_ui_interests_card_unfollow_button_content_desc) } } @@ -74,7 +75,10 @@ class InterestsScreenTest { fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() { composeTestRule.setContent { InterestsScreen( - uiState = InterestsUiState.Interests(topics = followableTopicTestData), + uiState = InterestsUiState.Interests( + topics = followableTopicTestData, + selectedTopicId = null, + ), ) } diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index e618c1c9f..468550878 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -35,9 +35,10 @@ import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParame import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent @Composable -internal fun InterestsRoute( +fun InterestsRoute( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + highlightSelectedTopic: Boolean = false, viewModel: InterestsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -45,7 +46,11 @@ internal fun InterestsRoute( InterestsScreen( uiState = uiState, followTopic = viewModel::followTopic, - onTopicClick = onTopicClick, + onTopicClick = { + viewModel.onTopicClick(it) + onTopicClick(it) + }, + highlightSelectedTopic = highlightSelectedTopic, modifier = modifier, ) } @@ -56,6 +61,7 @@ internal fun InterestsScreen( followTopic: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + highlightSelectedTopic: Boolean = false, ) { Column( modifier = modifier, @@ -65,15 +71,19 @@ internal fun InterestsScreen( InterestsUiState.Loading -> NiaLoadingWheel( modifier = modifier, - contentDesc = stringResource(id = R.string.loading), + contentDesc = stringResource(id = R.string.feature_interests_loading), ) + is InterestsUiState.Interests -> TopicsTabContent( topics = uiState.topics, onTopicClick = onTopicClick, onFollowButtonClick = followTopic, + selectedTopicId = uiState.selectedTopicId, + highlightSelectedTopic = highlightSelectedTopic, modifier = modifier, ) + is InterestsUiState.Empty -> InterestsEmptyScreen() } } @@ -82,7 +92,7 @@ internal fun InterestsScreen( @Composable private fun InterestsEmptyScreen() { - Text(text = stringResource(id = R.string.empty_header)) + Text(text = stringResource(id = R.string.feature_interests_empty_header)) } @DevicePreviews @@ -95,6 +105,7 @@ fun InterestsScreenPopulated( NiaBackground { InterestsScreen( uiState = InterestsUiState.Interests( + selectedTopicId = null, topics = followableTopics, ), followTopic = { _, _ -> }, diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt index 6d905a6d5..b369ac5ab 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt @@ -16,46 +16,57 @@ package com.google.samples.apps.nowinandroid.feature.interests +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.TopicSortField import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class InterestsViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, val userDataRepository: UserDataRepository, getFollowableTopics: GetFollowableTopicsUseCase, ) : ViewModel() { - val uiState: StateFlow = - getFollowableTopics(sortBy = TopicSortField.NAME).map( - InterestsUiState::Interests, - ).stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = InterestsUiState.Loading, - ) + val selectedTopicId: StateFlow = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null) + + val uiState: StateFlow = combine( + selectedTopicId, + getFollowableTopics(sortBy = TopicSortField.NAME), + InterestsUiState::Interests, + ).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InterestsUiState.Loading, + ) fun followTopic(followedTopicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.setTopicIdFollowed(followedTopicId, followed) } } + + fun onTopicClick(topicId: String?) { + savedStateHandle[TOPIC_ID_ARG] = topicId + } } sealed interface InterestsUiState { data object Loading : InterestsUiState data class Interests( + val selectedTopicId: String?, val topics: List, ) : InterestsUiState diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index d865f5e1a..83058c12e 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -39,6 +39,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollba import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.ui.InterestsItem @Composable fun TopicsTabContent( @@ -47,6 +48,8 @@ fun TopicsTabContent( onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, withBottomSpacer: Boolean = true, + selectedTopicId: String? = null, + highlightSelectedTopic: Boolean = false, ) { Box( modifier = modifier @@ -63,6 +66,7 @@ fun TopicsTabContent( topics.forEach { followableTopic -> val topicId = followableTopic.topic.id item(key = topicId) { + val isSelected = highlightSelectedTopic && topicId == selectedTopicId InterestsItem( name = followableTopic.topic.name, following = followableTopic.isFollowed, @@ -70,6 +74,7 @@ fun TopicsTabContent( topicImageUrl = followableTopic.topic.imageUrl, onClick = { onTopicClick(topicId) }, onFollowButtonClick = { onFollowButtonClick(topicId, it) }, + isSelected = isSelected, ) } } diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt index 7558ec48d..8a0f2d130 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt @@ -19,28 +19,37 @@ package com.google.samples.apps.nowinandroid.feature.interests.navigation import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable -import androidx.navigation.navigation +import androidx.navigation.navArgument import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute -private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph" -const val interestsRoute = "interests_route" +const val TOPIC_ID_ARG = "topicId" +const val INTERESTS_ROUTE_BASE = "interests_route" +const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}" -fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) { - this.navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions) +fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) { + val route = if (topicId != null) { + "${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId" + } else { + INTERESTS_ROUTE_BASE + } + navigate(route, navOptions) } -fun NavGraphBuilder.interestsGraph( +fun NavGraphBuilder.interestsScreen( onTopicClick: (String) -> Unit, - nestedGraphs: NavGraphBuilder.() -> Unit, ) { - navigation( - route = INTERESTS_GRAPH_ROUTE_PATTERN, - startDestination = interestsRoute, + composable( + route = INTERESTS_ROUTE, + arguments = listOf( + navArgument(TOPIC_ID_ARG) { + defaultValue = null + nullable = true + type = NavType.StringType + }, + ), ) { - composable(route = interestsRoute) { - InterestsRoute(onTopicClick) - } - nestedGraphs() + InterestsRoute(onTopicClick = onTopicClick) } } diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/src/main/res/values/strings.xml index 384cb1deb..8d5322859 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/src/main/res/values/strings.xml @@ -15,9 +15,7 @@ limitations under the License. --> - Interests - Loading data - "No available data" - Follow interest - Unfollow interest + Interests + Loading data + "No available data" diff --git a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt index c46cb7780..63d3c49b7 100644 --- a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt +++ b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.interests +import androidx.lifecycle.SavedStateHandle import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic @@ -24,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -53,6 +55,7 @@ class InterestsViewModelTest { @Before fun setup() { viewModel = InterestsViewModel( + savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), userDataRepository = userDataRepository, getFollowableTopics = getFollowableTopicsUseCase, ) @@ -93,7 +96,10 @@ class InterestsViewModelTest { ) assertEquals( - InterestsUiState.Interests(topics = testOutputTopics), + InterestsUiState.Interests( + topics = testOutputTopics, + selectedTopicId = testInputTopics[0].topic.id, + ), viewModel.uiState.value, ) @@ -123,7 +129,10 @@ class InterestsViewModelTest { ) assertEquals( - InterestsUiState.Interests(topics = testInputTopics), + InterestsUiState.Interests( + topics = testInputTopics, + selectedTopicId = testInputTopics[0].topic.id, + ), viewModel.uiState.value, ) diff --git a/feature/search/README.md b/feature/search/README.md new file mode 100644 index 000000000..e205970f0 --- /dev/null +++ b/feature/search/README.md @@ -0,0 +1,3 @@ +# :feature:search module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_feature_search.svg) diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index d96f290e3..c5f1f6ad0 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -25,9 +25,13 @@ android { } dependencies { - implementation(projects.feature.bookmarks) - implementation(projects.feature.foryou) - implementation(projects.feature.interests) - implementation(libs.kotlinx.datetime) + implementation(projects.core.data) + implementation(projects.core.domain) + implementation(projects.core.ui) + + testImplementation(projects.core.testing) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) + androidTestImplementation(projects.core.testing) } diff --git a/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt index d6c07221e..a9e2fa98f 100644 --- a/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt +++ b/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -35,10 +35,10 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData +import com.google.samples.apps.nowinandroid.core.ui.R.string import org.junit.Before import org.junit.Rule import org.junit.Test -import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR /** * UI test for checking the correct behaviour of the Search screen. @@ -70,17 +70,17 @@ class SearchScreenTest { @Before fun setup() { composeTestRule.activity.apply { - clearSearchContentDesc = getString(R.string.clear_search_text_content_desc) - clearRecentSearchesContentDesc = getString(R.string.clear_recent_searches_content_desc) + clearSearchContentDesc = getString(R.string.feature_search_clear_search_text_content_desc) + clearRecentSearchesContentDesc = getString(R.string.feature_search_clear_recent_searches_content_desc) followButtonContentDesc = - getString(interestsR.string.card_follow_button_content_desc) + getString(string.core_ui_interests_card_follow_button_content_desc) unfollowButtonContentDesc = - getString(interestsR.string.card_unfollow_button_content_desc) - topicsString = getString(R.string.topics) - updatesString = getString(R.string.updates) - tryAnotherSearchString = getString(R.string.try_another_search) + - " " + getString(R.string.interests) + " " + getString(R.string.to_browse_topics) - searchNotReadyString = getString(R.string.search_not_ready) + getString(string.core_ui_interests_card_unfollow_button_content_desc) + topicsString = getString(R.string.feature_search_topics) + updatesString = getString(R.string.feature_search_updates) + tryAnotherSearchString = getString(R.string.feature_search_try_another_search) + + " " + getString(R.string.feature_search_interests) + " " + getString(R.string.feature_search_to_browse_topics) + searchNotReadyString = getString(R.string.feature_search_not_ready) } } diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index 65b65f61d..86b1eb717 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -55,7 +55,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -88,62 +87,56 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.InterestsItem import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.R.string import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.newsFeed -import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksViewModel -import com.google.samples.apps.nowinandroid.feature.foryou.ForYouViewModel -import com.google.samples.apps.nowinandroid.feature.interests.InterestsItem -import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel import com.google.samples.apps.nowinandroid.feature.search.R as searchR @Composable internal fun SearchRoute( - modifier: Modifier = Modifier, onBackClick: () -> Unit, onInterestsClick: () -> Unit, onTopicClick: (String) -> Unit, - bookmarksViewModel: BookmarksViewModel = hiltViewModel(), - interestsViewModel: InterestsViewModel = hiltViewModel(), + modifier: Modifier = Modifier, searchViewModel: SearchViewModel = hiltViewModel(), - forYouViewModel: ForYouViewModel = hiltViewModel(), ) { val recentSearchQueriesUiState by searchViewModel.recentSearchQueriesUiState.collectAsStateWithLifecycle() val searchResultUiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle() val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() SearchScreen( modifier = modifier, - onBackClick = onBackClick, - onClearRecentSearches = searchViewModel::clearRecentSearches, - onFollowButtonClick = interestsViewModel::followTopic, - onInterestsClick = onInterestsClick, + searchQuery = searchQuery, + recentSearchesUiState = recentSearchQueriesUiState, + searchResultUiState = searchResultUiState, onSearchQueryChanged = searchViewModel::onSearchQueryChanged, onSearchTriggered = searchViewModel::onSearchTriggered, + onClearRecentSearches = searchViewModel::clearRecentSearches, + onNewsResourcesCheckedChanged = searchViewModel::setNewsResourceBookmarked, + onNewsResourceViewed = { searchViewModel.setNewsResourceViewed(it, true) }, + onFollowButtonClick = searchViewModel::followTopic, + onBackClick = onBackClick, + onInterestsClick = onInterestsClick, onTopicClick = onTopicClick, - onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved, - onNewsResourceViewed = { bookmarksViewModel.setNewsResourceViewed(it, true) }, - recentSearchesUiState = recentSearchQueriesUiState, - searchQuery = searchQuery, - searchResultUiState = searchResultUiState, ) } @Composable internal fun SearchScreen( modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, + searchQuery: String = "", + recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, + searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, + onSearchQueryChanged: (String) -> Unit = {}, + onSearchTriggered: (String) -> Unit = {}, onClearRecentSearches: () -> Unit = {}, - onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, - onInterestsClick: () -> Unit = {}, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> }, onNewsResourceViewed: (String) -> Unit = {}, - onSearchQueryChanged: (String) -> Unit = {}, - onSearchTriggered: (String) -> Unit = {}, + onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, + onBackClick: () -> Unit = {}, + onInterestsClick: () -> Unit = {}, onTopicClick: (String) -> Unit = {}, - searchQuery: String = "", - recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, - searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, ) { TrackScreenViewEvent(screenName = "Search") Column(modifier = modifier) { @@ -177,8 +170,8 @@ internal fun SearchScreen( is SearchResultUiState.Success -> { if (searchResultUiState.isEmpty()) { EmptySearchResultBody( - onInterestsClick = onInterestsClick, searchQuery = searchQuery, + onInterestsClick = onInterestsClick, ) if (recentSearchesUiState is RecentSearchQueriesUiState.Success) { RecentSearchesBody( @@ -192,14 +185,14 @@ internal fun SearchScreen( } } else { SearchResultBody( + searchQuery = searchQuery, topics = searchResultUiState.topics, - onFollowButtonClick = onFollowButtonClick, - onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, - onNewsResourceViewed = onNewsResourceViewed, + newsResources = searchResultUiState.newsResources, onSearchTriggered = onSearchTriggered, onTopicClick = onTopicClick, - newsResources = searchResultUiState.newsResources, - searchQuery = searchQuery, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onFollowButtonClick = onFollowButtonClick, ) } } @@ -210,14 +203,14 @@ internal fun SearchScreen( @Composable fun EmptySearchResultBody( - onInterestsClick: () -> Unit, searchQuery: String, + onInterestsClick: () -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 48.dp), ) { - val message = stringResource(id = searchR.string.search_result_not_found, searchQuery) + val message = stringResource(id = searchR.string.feature_search_result_not_found, searchQuery) val start = message.indexOf(searchQuery) Text( text = AnnotatedString( @@ -234,9 +227,9 @@ fun EmptySearchResultBody( textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 24.dp), ) - val interests = stringResource(id = searchR.string.interests) + val interests = stringResource(id = searchR.string.feature_search_interests) val tryAnotherSearchString = buildAnnotatedString { - append(stringResource(id = searchR.string.try_another_search)) + append(stringResource(id = searchR.string.feature_search_try_another_search)) append(" ") withStyle( style = SpanStyle( @@ -248,7 +241,7 @@ fun EmptySearchResultBody( append(interests) } append(" ") - append(stringResource(id = searchR.string.to_browse_topics)) + append(stringResource(id = searchR.string.feature_search_to_browse_topics)) } ClickableText( text = tryAnotherSearchString, @@ -264,9 +257,7 @@ fun EmptySearchResultBody( ) { offset -> tryAnotherSearchString.getStringAnnotations(start = offset, end = offset) .firstOrNull() - ?.let { - onInterestsClick() - } + ?.let { onInterestsClick() } } } } @@ -278,7 +269,7 @@ private fun SearchNotReadyBody() { modifier = Modifier.padding(horizontal = 48.dp), ) { Text( - text = stringResource(id = searchR.string.search_not_ready), + text = stringResource(id = searchR.string.feature_search_not_ready), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 24.dp), @@ -288,14 +279,14 @@ private fun SearchNotReadyBody() { @Composable private fun SearchResultBody( + searchQuery: String, topics: List, newsResources: List, - onFollowButtonClick: (String, Boolean) -> Unit, - onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, - onNewsResourceViewed: (String) -> Unit, onSearchTriggered: (String) -> Unit, onTopicClick: (String) -> Unit, - searchQuery: String = "", + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, + onFollowButtonClick: (String, Boolean) -> Unit, ) { val state = rememberLazyStaggeredGridState() Box( @@ -319,7 +310,7 @@ private fun SearchResultBody( Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.topics)) + append(stringResource(id = searchR.string.feature_search_topics)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -328,7 +319,8 @@ private fun SearchResultBody( topics.forEach { followableTopic -> val topicId = followableTopic.topic.id item( - key = "topic-$topicId", // Append a prefix to distinguish a key for news resources + // Append a prefix to distinguish a key for news resources + key = "topic-$topicId", span = StaggeredGridItemSpan.FullLine, ) { InterestsItem( @@ -354,7 +346,7 @@ private fun SearchResultBody( Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.updates)) + append(stringResource(id = searchR.string.feature_search_updates)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -393,9 +385,9 @@ private fun SearchResultBody( @Composable private fun RecentSearchesBody( + recentSearchQueries: List, onClearRecentSearches: () -> Unit, onRecentSearchClicked: (String) -> Unit, - recentSearchQueries: List, ) { Column { Row( @@ -406,7 +398,7 @@ private fun RecentSearchesBody( Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.recent_searches)) + append(stringResource(id = searchR.string.feature_search_recent_searches)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -421,7 +413,7 @@ private fun RecentSearchesBody( Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.clear_recent_searches_content_desc, + id = searchR.string.feature_search_clear_recent_searches_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) @@ -445,11 +437,11 @@ private fun RecentSearchesBody( @Composable private fun SearchToolbar( - modifier: Modifier = Modifier, - onBackClick: () -> Unit, + searchQuery: String, onSearchQueryChanged: (String) -> Unit, - searchQuery: String = "", onSearchTriggered: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -459,7 +451,7 @@ private fun SearchToolbar( Icon( imageVector = NiaIcons.ArrowBack, contentDescription = stringResource( - id = string.back, + id = string.core_ui_back, ), ) } @@ -471,11 +463,10 @@ private fun SearchToolbar( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun SearchTextField( - onSearchQueryChanged: (String) -> Unit, searchQuery: String, + onSearchQueryChanged: (String) -> Unit, onSearchTriggered: (String) -> Unit, ) { val focusRequester = remember { FocusRequester() } @@ -496,7 +487,7 @@ private fun SearchTextField( Icon( imageVector = NiaIcons.Search, contentDescription = stringResource( - id = searchR.string.search, + id = searchR.string.feature_search_title, ), tint = MaterialTheme.colorScheme.onSurface, ) @@ -511,7 +502,7 @@ private fun SearchTextField( Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.clear_search_text_content_desc, + id = searchR.string.feature_search_clear_search_text_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) @@ -519,9 +510,7 @@ private fun SearchTextField( } }, onValueChange = { - if (!it.contains("\n")) { - onSearchQueryChanged(it) - } + if ("\n" !in it) onSearchQueryChanged(it) }, modifier = Modifier .fillMaxWidth() @@ -559,6 +548,7 @@ private fun SearchTextField( private fun SearchToolbarPreview() { NiaTheme { SearchToolbar( + searchQuery = "", onBackClick = {}, onSearchQueryChanged = {}, onSearchTriggered = {}, diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt index 4268893da..257d8b68e 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.feature.search import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -21,7 +23,6 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics -/* ktlint-disable max-line-length */ /** * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) * provides list of [SearchResultUiState] for Composable previews. diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt index 6dd93ceb6..6c2af240c 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt @@ -23,14 +23,15 @@ 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 import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsCountUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase -import com.google.samples.apps.nowinandroid.core.result.Result -import com.google.samples.apps.nowinandroid.core.result.asResult +import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -41,9 +42,10 @@ import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( getSearchContentsUseCase: GetSearchContentsUseCase, - getSearchContentsCountUseCase: GetSearchContentsCountUseCase, recentSearchQueriesUseCase: GetRecentSearchQueriesUseCase, + private val searchContentsRepository: SearchContentsRepository, private val recentSearchRepository: RecentSearchRepository, + private val userDataRepository: UserDataRepository, private val savedStateHandle: SavedStateHandle, private val analyticsHelper: AnalyticsHelper, ) : ViewModel() { @@ -51,7 +53,7 @@ class SearchViewModel @Inject constructor( val searchQuery = savedStateHandle.getStateFlow(key = SEARCH_QUERY, initialValue = "") val searchResultUiState: StateFlow = - getSearchContentsCountUseCase() + searchContentsRepository.getSearchContentsCount() .flatMapLatest { totalCount -> if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) { flowOf(SearchResultUiState.SearchNotReady) @@ -61,18 +63,15 @@ class SearchViewModel @Inject constructor( flowOf(SearchResultUiState.EmptyQuery) } else { getSearchContentsUseCase(query) - .asResult() - .map { result -> - when (result) { - is Result.Success -> SearchResultUiState.Success( - topics = result.data.topics, - newsResources = result.data.newsResources, - ) - - is Result.Loading -> SearchResultUiState.Loading - is Result.Error -> SearchResultUiState.LoadFailed - } + // Not using .asResult() here, because it emits Loading state every + // time the user types a letter in the search box, which flickers the screen. + .map { data -> + SearchResultUiState.Success( + topics = data.topics, + newsResources = data.newsResources, + ) } + .catch { emit(SearchResultUiState.LoadFailed) } } } } @@ -114,6 +113,24 @@ class SearchViewModel @Inject constructor( recentSearchRepository.clearRecentSearches() } } + + fun setNewsResourceBookmarked(newsResourceId: String, isChecked: Boolean) { + viewModelScope.launch { + userDataRepository.setNewsResourceBookmarked(newsResourceId, isChecked) + } + } + + fun followTopic(followedTopicId: String, followed: Boolean) { + viewModelScope.launch { + userDataRepository.setTopicIdFollowed(followedTopicId, followed) + } + } + + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + viewModelScope.launch { + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) + } + } } private fun AnalyticsHelper.logEventSearchTriggered(query: String) = diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt index 42bf3f475..81f3576b4 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt @@ -22,11 +22,9 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.google.samples.apps.nowinandroid.feature.search.SearchRoute -const val searchRoute = "search_route" +const val SEARCH_ROUTE = "search_route" -fun NavController.navigateToSearch(navOptions: NavOptions? = null) { - this.navigate(searchRoute, navOptions) -} +fun NavController.navigateToSearch(navOptions: NavOptions? = null) = navigate(SEARCH_ROUTE, navOptions) fun NavGraphBuilder.searchScreen( onBackClick: () -> Unit, @@ -35,7 +33,7 @@ fun NavGraphBuilder.searchScreen( ) { // TODO: Handle back stack for each top-level destination. At the moment each top-level // destination may have own search screen's back stack. - composable(route = searchRoute) { + composable(route = SEARCH_ROUTE) { SearchRoute( onBackClick = onBackClick, onInterestsClick = onInterestsClick, diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index 2a824653e..e11576747 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -15,15 +15,15 @@ limitations under the License. --> - Search - Clear search text - Sorry, there is no content found for your search \"%1$s\" - Sorry, we are still processing the search index. Please come back later. - Try another search or explorer - Interests - to browse topics - Topics - Updates - Recent searches - Clear searches + Search + Clear search text + Sorry, there is no content found for your search \"%1$s\" + Sorry, we are still processing the search index. Please come back later + Try another search or explorer + Interests + to browse topics + Topics + Updates + Recent searches + Clear searches diff --git a/feature/search/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt b/feature/search/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt index fc9c20549..c832401de 100644 --- a/feature/search/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt +++ b/feature/search/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt @@ -19,19 +19,20 @@ package com.google.samples.apps.nowinandroid.feature.search import androidx.lifecycle.SavedStateHandle import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsCountUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.topicsTestData import com.google.samples.apps.nowinandroid.core.testing.repository.TestRecentSearchRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.feature.search.RecentSearchQueriesUiState.Success import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.Loading import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.SearchNotReady import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -58,19 +59,21 @@ class SearchViewModelTest { ) private val recentSearchRepository = TestRecentSearchRepository() private val getRecentQueryUseCase = GetRecentSearchQueriesUseCase(recentSearchRepository) - private val getSearchContentsCountUseCase = GetSearchContentsCountUseCase(searchContentsRepository) + private lateinit var viewModel: SearchViewModel @Before fun setup() { viewModel = SearchViewModel( getSearchContentsUseCase = getSearchContentsUseCase, - getSearchContentsCountUseCase = getSearchContentsCountUseCase, recentSearchQueriesUseCase = getRecentQueryUseCase, + searchContentsRepository = searchContentsRepository, savedStateHandle = SavedStateHandle(), recentSearchRepository = recentSearchRepository, + userDataRepository = userDataRepository, analyticsHelper = NoOpAnalyticsHelper(), ) + userDataRepository.setUserData(emptyUserData) } @Test @@ -82,7 +85,8 @@ class SearchViewModelTest { fun stateIsEmptyQuery_withEmptySearchQuery() = runTest { searchContentsRepository.addNewsResources(newsResourcesTestData) searchContentsRepository.addTopics(topicsTestData) - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + val collectJob = + launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } viewModel.onSearchQueryChanged("") @@ -93,22 +97,23 @@ class SearchViewModelTest { @Test fun emptyResultIsReturned_withNotMatchingQuery() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + val collectJob = + launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } viewModel.onSearchQueryChanged("XXX") searchContentsRepository.addNewsResources(newsResourcesTestData) searchContentsRepository.addTopics(topicsTestData) val result = viewModel.searchResultUiState.value - // TODO: Figure out to get the latest emitted ui State? The result is emitted as EmptyQuery - // assertIs(result) + assertIs(result) collectJob.cancel() } @Test fun recentSearches_verifyUiStateIsSuccess() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() } + val collectJob = + launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() } viewModel.onSearchTriggered("kotlin") val result = viewModel.recentSearchQueriesUiState.value @@ -119,7 +124,8 @@ class SearchViewModelTest { @Test fun searchNotReady_withNoFtsTableEntity() = runTest { - val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + val collectJob = + launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } viewModel.onSearchQueryChanged("") @@ -127,4 +133,22 @@ class SearchViewModelTest { collectJob.cancel() } + + @Test + fun whenToggleNewsResourceSavedIsCalled_bookmarkStateIsUpdated() = runTest { + val newsResourceId = "123" + viewModel.setNewsResourceBookmarked(newsResourceId, true) + + assertEquals( + expected = setOf(newsResourceId), + actual = userDataRepository.userData.first().bookmarkedNewsResources, + ) + + viewModel.setNewsResourceBookmarked(newsResourceId, false) + + assertEquals( + expected = emptySet(), + actual = userDataRepository.userData.first().bookmarkedNewsResources, + ) + } } diff --git a/feature/settings/README.md b/feature/settings/README.md new file mode 100644 index 000000000..7a4df04fe --- /dev/null +++ b/feature/settings/README.md @@ -0,0 +1,3 @@ +# :feature:settings module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_feature_settings.svg) diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 4f5d649b7..15d65204d 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -26,7 +26,10 @@ android { dependencies { implementation(libs.androidx.appcompat) - implementation(libs.google.oss.licenses) { - exclude(group = "androidx.appcompat") - } + implementation(libs.google.oss.licenses) + implementation(projects.core.data) + + testImplementation(projects.core.testing) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) } diff --git a/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt b/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt index febc606b0..790b5964d 100644 --- a/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt +++ b/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt @@ -48,7 +48,7 @@ class SettingsDialogTest { } composeTestRule - .onNodeWithText(getString(R.string.loading)) + .onNodeWithText(getString(R.string.feature_settings_loading)) .assertExists() } @@ -71,17 +71,17 @@ class SettingsDialogTest { } // Check that all the possible settings are displayed. - composeTestRule.onNodeWithText(getString(R.string.brand_default)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.brand_android)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_default)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertExists() composeTestRule.onNodeWithText( - getString(R.string.dark_mode_config_system_default), + getString(R.string.feature_settings_dark_mode_config_system_default), ).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_light)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_dark)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_light)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertExists() // Check that the correct settings are selected. - composeTestRule.onNodeWithText(getString(R.string.brand_android)).assertIsSelected() - composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_dark)).assertIsSelected() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertIsSelected() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertIsSelected() } @Test @@ -103,12 +103,12 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertExists() // Check that the correct default dynamic color setting is selected. - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertIsSelected() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertIsSelected() } @Test @@ -129,10 +129,10 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference)) + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)) .assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist() } @Test @@ -153,10 +153,10 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference)) + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)) .assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist() } @Test @@ -177,9 +177,9 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.privacy_policy)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.licenses)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.brand_guidelines)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.feedback)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_privacy_policy)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_licenses)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_guidelines)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_feedback)).assertExists() } } diff --git a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt index 01ec30e74..db60a6447 100644 --- a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.feature.settings import android.content.Intent @@ -35,7 +37,7 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text @@ -108,17 +110,17 @@ fun SettingsDialog( onDismissRequest = { onDismiss() }, title = { Text( - text = stringResource(string.settings_title), + text = stringResource(string.feature_settings_title), style = MaterialTheme.typography.titleLarge, ) }, text = { - Divider() + HorizontalDivider() Column(Modifier.verticalScroll(rememberScrollState())) { when (settingsUiState) { Loading -> { Text( - text = stringResource(string.loading), + text = stringResource(string.feature_settings_loading), modifier = Modifier.padding(vertical = 16.dp), ) } @@ -133,14 +135,14 @@ fun SettingsDialog( ) } } - Divider(Modifier.padding(top = 8.dp)) + HorizontalDivider(Modifier.padding(top = 8.dp)) LinksPanel() } TrackScreenViewEvent(screenName = "Settings") }, confirmButton = { Text( - text = stringResource(string.dismiss_dialog_button_text), + text = stringResource(string.feature_settings_dismiss_dialog_button_text), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier @@ -160,50 +162,50 @@ private fun ColumnScope.SettingsPanel( onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, ) { - SettingsDialogSectionTitle(text = stringResource(string.theme)) + SettingsDialogSectionTitle(text = stringResource(string.feature_settings_theme)) Column(Modifier.selectableGroup()) { SettingsDialogThemeChooserRow( - text = stringResource(string.brand_default), + text = stringResource(string.feature_settings_brand_default), selected = settings.brand == DEFAULT, onClick = { onChangeThemeBrand(DEFAULT) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.brand_android), + text = stringResource(string.feature_settings_brand_android), selected = settings.brand == ANDROID, onClick = { onChangeThemeBrand(ANDROID) }, ) } AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) { Column { - SettingsDialogSectionTitle(text = stringResource(string.dynamic_color_preference)) + SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dynamic_color_preference)) Column(Modifier.selectableGroup()) { SettingsDialogThemeChooserRow( - text = stringResource(string.dynamic_color_yes), + text = stringResource(string.feature_settings_dynamic_color_yes), selected = settings.useDynamicColor, onClick = { onChangeDynamicColorPreference(true) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.dynamic_color_no), + text = stringResource(string.feature_settings_dynamic_color_no), selected = !settings.useDynamicColor, onClick = { onChangeDynamicColorPreference(false) }, ) } } } - SettingsDialogSectionTitle(text = stringResource(string.dark_mode_preference)) + SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dark_mode_preference)) Column(Modifier.selectableGroup()) { SettingsDialogThemeChooserRow( - text = stringResource(string.dark_mode_config_system_default), + text = stringResource(string.feature_settings_dark_mode_config_system_default), selected = settings.darkThemeConfig == FOLLOW_SYSTEM, onClick = { onChangeDarkThemeConfig(FOLLOW_SYSTEM) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.dark_mode_config_light), + text = stringResource(string.feature_settings_dark_mode_config_light), selected = settings.darkThemeConfig == LIGHT, onClick = { onChangeDarkThemeConfig(LIGHT) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.dark_mode_config_dark), + text = stringResource(string.feature_settings_dark_mode_config_dark), selected = settings.darkThemeConfig == DARK, onClick = { onChangeDarkThemeConfig(DARK) }, ) @@ -259,7 +261,7 @@ private fun LinksPanel() { NiaTextButton( onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, ) { - Text(text = stringResource(string.privacy_policy)) + Text(text = stringResource(string.feature_settings_privacy_policy)) } val context = LocalContext.current NiaTextButton( @@ -267,17 +269,17 @@ private fun LinksPanel() { context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) }, ) { - Text(text = stringResource(string.licenses)) + Text(text = stringResource(string.feature_settings_licenses)) } NiaTextButton( onClick = { uriHandler.openUri(BRAND_GUIDELINES_URL) }, ) { - Text(text = stringResource(string.brand_guidelines)) + Text(text = stringResource(string.feature_settings_brand_guidelines)) } NiaTextButton( onClick = { uriHandler.openUri(FEEDBACK_URL) }, ) { - Text(text = stringResource(string.feedback)) + Text(text = stringResource(string.feature_settings_feedback)) } } } @@ -316,7 +318,6 @@ private fun PreviewSettingsDialogLoading() { } } -/* ktlint-disable max-line-length */ private const val PRIVACY_POLICY_URL = "https://policies.google.com/privacy" private const val BRAND_GUIDELINES_URL = "https://developer.android.com/distribute/marketing-tools/brand-guidelines" private const val FEEDBACK_URL = "https://goo.gle/nia-app-feedback" diff --git a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt index a49c0d512..123c84d1c 100644 --- a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt @@ -24,12 +24,13 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class SettingsViewModel @Inject constructor( @@ -48,13 +49,7 @@ class SettingsViewModel @Inject constructor( } .stateIn( scope = viewModelScope, - // Starting eagerly means the user data is ready when the SettingsDialog is laid out - // for the first time. Without this, due to b/221643630 the layout is done using the - // "Loading" text, then replaced with the user editable fields once loaded, however, - // the layout height doesn't change meaning all the fields are squashed into a small - // scrollable column. - // TODO: Change to SharingStarted.WhileSubscribed(5_000) when b/221643630 is fixed - started = SharingStarted.Eagerly, + started = WhileSubscribed(5.seconds.inWholeMilliseconds), initialValue = Loading, ) diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index ad56f6b08..887539bd5 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -15,23 +15,23 @@ limitations under the License. --> - Settings - Search - Settings - Loading… - Privacy policy - Licenses - Brand Guidelines - Feedback - Theme - Default - Android - Dark mode preference - System default - Light - Dark - Use Dynamic Color - Yes - No - OK + Settings + Search + Settings + Loading… + Privacy policy + Licenses + Brand Guidelines + Feedback + Theme + Default + Android + Dark mode preference + System default + Light + Dark + Use Dynamic Color + Yes + No + OK diff --git a/feature/topic/README.md b/feature/topic/README.md index d74517e63..84588929c 100644 --- a/feature/topic/README.md +++ b/feature/topic/README.md @@ -1,3 +1,3 @@ # :feature:topic module - -![Dependency graph](../../docs/images/graphs/dep_graph_feature_topic.png) +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_feature_topic.svg) diff --git a/feature/topic/build.gradle.kts b/feature/topic/build.gradle.kts index cc0ecc868..726920af1 100644 --- a/feature/topic/build.gradle.kts +++ b/feature/topic/build.gradle.kts @@ -25,5 +25,10 @@ android { } dependencies { - implementation(libs.kotlinx.datetime) + implementation(projects.core.data) + + testImplementation(projects.core.testing) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) + androidTestImplementation(projects.core.testing) } \ No newline at end of file diff --git a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index 94f86a8e4..2b87baf9e 100644 --- a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -45,7 +45,7 @@ class TopicScreenTest { @Before fun setup() { composeTestRule.activity.apply { - topicLoading = getString(R.string.topic_loading) + topicLoading = getString(R.string.feature_topic_loading) } } @@ -55,6 +55,7 @@ class TopicScreenTest { TopicScreen( topicUiState = TopicUiState.Loading, newsUiState = NewsUiState.Loading, + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, @@ -75,6 +76,7 @@ class TopicScreenTest { TopicScreen( topicUiState = TopicUiState.Success(testTopic), newsUiState = NewsUiState.Loading, + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, @@ -100,6 +102,7 @@ class TopicScreenTest { TopicScreen( topicUiState = TopicUiState.Loading, newsUiState = NewsUiState.Success(userNewsResourcesTestData), + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, @@ -123,6 +126,7 @@ class TopicScreenTest { newsUiState = NewsUiState.Success( userNewsResourcesTestData, ), + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicDetailPlaceholder.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicDetailPlaceholder.kt new file mode 100644 index 000000000..627fb8fb3 --- /dev/null +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicDetailPlaceholder.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.topic + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme + +@Composable +fun TopicDetailPlaceholder(modifier: Modifier = Modifier) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + 20.dp, + alignment = Alignment.CenterVertically, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.feature_topic_ic_topic_placeholder), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(id = R.string.feature_topic_select_an_interest), + style = MaterialTheme.typography.titleLarge, + ) + } + } +} + +@Preview(widthDp = 200, heightDp = 300) +@Composable +fun TopicDetailPlaceholderPreview() { + NiaTheme { + TopicDetailPlaceholder() + } +} diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index 3dbbe7da8..5ac766675 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight @@ -44,6 +45,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -70,19 +72,21 @@ import com.google.samples.apps.nowinandroid.feature.topic.R.string @Composable internal fun TopicRoute( + showBackButton: Boolean, onBackClick: () -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, viewModel: TopicViewModel = hiltViewModel(), ) { val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle() - val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle() + val newsUiState: NewsUiState by viewModel.newsUiState.collectAsStateWithLifecycle() TrackScreenViewEvent(screenName = "Topic: ${viewModel.topicId}") TopicScreen( topicUiState = topicUiState, newsUiState = newsUiState, - modifier = modifier, + modifier = modifier.testTag("topic:${viewModel.topicId}"), + showBackButton = showBackButton, onBackClick = onBackClick, onFollowClick = viewModel::followTopicToggle, onBookmarkChanged = viewModel::bookmarkNews, @@ -96,6 +100,7 @@ internal fun TopicRoute( internal fun TopicScreen( topicUiState: TopicUiState, newsUiState: NewsUiState, + showBackButton: Boolean, onBackClick: () -> Unit, onFollowClick: (Boolean) -> Unit, onTopicClick: (String) -> Unit, @@ -119,7 +124,7 @@ internal fun TopicScreen( TopicUiState.Loading -> item { NiaLoadingWheel( modifier = modifier, - contentDesc = stringResource(id = string.topic_loading), + contentDesc = stringResource(id = string.feature_topic_loading), ) } @@ -127,6 +132,7 @@ internal fun TopicScreen( is TopicUiState.Success -> { item { TopicToolbar( + showBackButton = showBackButton, onBackClick = onBackClick, onFollowClick = onFollowClick, uiState = topicUiState.followableTopic, @@ -270,6 +276,7 @@ private fun TopicBodyPreview() { private fun TopicToolbar( uiState: FollowableTopic, modifier: Modifier = Modifier, + showBackButton: Boolean = true, onBackClick: () -> Unit = {}, onFollowClick: (Boolean) -> Unit = {}, ) { @@ -280,13 +287,18 @@ private fun TopicToolbar( .fillMaxWidth() .padding(bottom = 32.dp), ) { - IconButton(onClick = { onBackClick() }) { - Icon( - imageVector = NiaIcons.ArrowBack, - contentDescription = stringResource( - id = com.google.samples.apps.nowinandroid.core.ui.R.string.back, - ), - ) + if (showBackButton) { + IconButton(onClick = { onBackClick() }) { + Icon( + imageVector = NiaIcons.ArrowBack, + contentDescription = stringResource( + id = com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_back, + ), + ) + } + } else { + // Keeps the NiaFilterChip aligned to the end of the Row. + Spacer(modifier = Modifier.width(1.dp)) } val selected = uiState.isFollowed NiaFilterChip( @@ -314,6 +326,7 @@ fun TopicScreenPopulated( TopicScreen( topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]), newsUiState = NewsUiState.Success(userNewsResources), + showBackButton = true, onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, @@ -332,6 +345,7 @@ fun TopicScreenLoading() { TopicScreen( topicUiState = TopicUiState.Loading, newsUiState = NewsUiState.Loading, + showBackButton = true, onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index 6adfe0a67..255e40f8b 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -62,7 +62,7 @@ class TopicViewModel @Inject constructor( initialValue = TopicUiState.Loading, ) - val newUiState: StateFlow = newsUiState( + val newsUiState: StateFlow = newsUiState( topicId = topicArgs.topicId, userDataRepository = userDataRepository, userNewsResourceRepository = userNewsResourceRepository, @@ -81,7 +81,7 @@ class TopicViewModel @Inject constructor( fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) { viewModelScope.launch { - userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) + userDataRepository.setNewsResourceBookmarked(newsResourceId, bookmarked) } } @@ -117,22 +117,16 @@ private fun topicUiState( when (followedTopicToTopicResult) { is Result.Success -> { val (followedTopics, topic) = followedTopicToTopicResult.data - val followed = followedTopics.contains(topicId) TopicUiState.Success( followableTopic = FollowableTopic( topic = topic, - isFollowed = followed, + isFollowed = topicId in followedTopics, ), ) } - is Result.Loading -> { - TopicUiState.Loading - } - - is Result.Error -> { - TopicUiState.Error - } + is Result.Loading -> TopicUiState.Loading + is Result.Error -> TopicUiState.Error } } } @@ -151,26 +145,13 @@ private fun newsUiState( val bookmark: Flow> = userDataRepository.userData .map { it.bookmarkedNewsResources } - return combine( - newsStream, - bookmark, - ::Pair, - ) + return combine(newsStream, bookmark, ::Pair) .asResult() .map { newsToBookmarksResult -> when (newsToBookmarksResult) { - is Result.Success -> { - val news = newsToBookmarksResult.data.first - NewsUiState.Success(news) - } - - is Result.Loading -> { - NewsUiState.Loading - } - - is Result.Error -> { - NewsUiState.Error - } + is Result.Success -> NewsUiState.Success(newsToBookmarksResult.data.first) + is Result.Loading -> NewsUiState.Loading + is Result.Error -> NewsUiState.Error } } } diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt index c29b57d47..394c53303 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt @@ -20,6 +20,7 @@ import androidx.annotation.VisibleForTesting import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument @@ -31,30 +32,40 @@ import kotlin.text.Charsets.UTF_8 private val URL_CHARACTER_ENCODING = UTF_8.name() @VisibleForTesting -internal const val topicIdArg = "topicId" +internal const val TOPIC_ID_ARG = "topicId" +const val TOPIC_ROUTE = "topic_route" internal class TopicArgs(val topicId: String) { constructor(savedStateHandle: SavedStateHandle) : - this(URLDecoder.decode(checkNotNull(savedStateHandle[topicIdArg]), URL_CHARACTER_ENCODING)) + this(URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING)) } -fun NavController.navigateToTopic(topicId: String) { - val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING) - this.navigate("topic_route/$encodedId") { - launchSingleTop = true +fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { + navigate(createTopicRoute(topicId)) { + navOptions() } } +fun createTopicRoute(topicId: String): String { + val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING) + return "$TOPIC_ROUTE/$encodedId" +} + fun NavGraphBuilder.topicScreen( + showBackButton: Boolean, onBackClick: () -> Unit, onTopicClick: (String) -> Unit, ) { composable( - route = "topic_route/{$topicIdArg}", + route = "topic_route/{$TOPIC_ID_ARG}", arguments = listOf( - navArgument(topicIdArg) { type = NavType.StringType }, + navArgument(TOPIC_ID_ARG) { type = NavType.StringType }, ), ) { - TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick) + TopicRoute( + showBackButton = showBackButton, + onBackClick = onBackClick, + onTopicClick = onTopicClick, + ) } } diff --git a/feature/topic/src/main/res/drawable/feature_topic_ic_topic_placeholder.xml b/feature/topic/src/main/res/drawable/feature_topic_ic_topic_placeholder.xml new file mode 100644 index 000000000..0518401da --- /dev/null +++ b/feature/topic/src/main/res/drawable/feature_topic_ic_topic_placeholder.xml @@ -0,0 +1,55 @@ + + + + + + + + + + diff --git a/feature/topic/src/main/res/values/strings.xml b/feature/topic/src/main/res/values/strings.xml index 284f2f7b2..fe4a6dc29 100644 --- a/feature/topic/src/main/res/values/strings.xml +++ b/feature/topic/src/main/res/values/strings.xml @@ -15,5 +15,6 @@ limitations under the License. --> - Loading topic + Loading topic + Select an Interest diff --git a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index 8c6253d60..565732f59 100644 --- a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -25,7 +25,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule -import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicIdArg +import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ID_ARG import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @@ -60,7 +60,7 @@ class TopicViewModelTest { @Before fun setup() { viewModel = TopicViewModel( - savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)), + savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), userDataRepository = userDataRepository, topicsRepository = topicsRepository, userNewsResourceRepository = userNewsResourceRepository, @@ -91,7 +91,7 @@ class TopicViewModelTest { @Test fun uiStateNews_whenInitialized_thenShowLoading() = runTest { - assertEquals(NewsUiState.Loading, viewModel.newUiState.value) + assertEquals(NewsUiState.Loading, viewModel.newsUiState.value) } @Test @@ -117,7 +117,7 @@ class TopicViewModelTest { topicsRepository.sendTopics(testInputTopics.map { it.topic }) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) val topicUiState = viewModel.topicUiState.value - val newsUiState = viewModel.newUiState.value + val newsUiState = viewModel.newsUiState.value assertIs(topicUiState) assertIs(newsUiState) @@ -131,7 +131,7 @@ class TopicViewModelTest { val collectJob = launch(UnconfinedTestDispatcher()) { combine( viewModel.topicUiState, - viewModel.newUiState, + viewModel.newsUiState, ::Pair, ).collect() } @@ -139,7 +139,7 @@ class TopicViewModelTest { userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) newsRepository.sendNewsResources(sampleNewsResources) val topicUiState = viewModel.topicUiState.value - val newsUiState = viewModel.newUiState.value + val newsUiState = viewModel.newsUiState.value assertIs(topicUiState) assertIs(newsUiState) diff --git a/generateModuleGraphs.sh b/generateModuleGraphs.sh new file mode 100755 index 000000000..3c3583e67 --- /dev/null +++ b/generateModuleGraphs.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# +# Copyright 2024 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# +# Script to generate dependency graphs for each of the modules. The --exclude-module parameter can +# be used to exclude modules which are not part of the root dependency graph (and which, if included +# would cause the script to fail. +# +# Usage: generateModuleGraphs.sh --exclude-module :benchmarks --exclude-module :lint --exclude-module :ui-test-hilt-manifest + +# Check if the dot command is available +if ! command -v dot &> /dev/null +then + echo "The 'dot' command is not found. This is required to generate SVGs from the Graphviz files." + echo "Installation instructions:" + echo " - On macOS: You can install Graphviz using Homebrew with the command: 'brew install graphviz'" + echo " - On Ubuntu: You can install Graphviz using APT with the command: 'sudo apt-get install graphviz'" + exit 1 +fi + +# Check if the svgo command is available +if ! command -v svgo &> /dev/null +then + echo "The 'svgo' command is not found. This is required to cleanup and compress SVGs." + echo "Installation instructions available at https://github.com/svg/svgo." + exit 1 +fi + +# Check for a version of grep which supports Perl regex. +# On MacOS the OS installed grep doesn't support Perl regex so check for the existence of the +# GNU version instead which is prefixed with 'g' to distinguish it from the OS installed version. + if grep -P "" /dev/null > /dev/null 2>&1; then + GREP_COMMAND=grep +elif command -v ggrep &> /dev/null; then + GREP_COMMAND=ggrep +else + echo "You don't have a version of 'grep' installed which supports Perl regular expressions." + echo "On MacOS you can install one using Homebrew with the command: 'brew install grep'" + exit 1 +fi + +# Initialize an array to store excluded modules +excluded_modules=() + +# Parse command-line arguments for excluded modules +while [[ $# -gt 0 ]]; do + case "$1" in + --exclude-module) + excluded_modules+=("$2") + shift # Past argument + shift # Past value + ;; + *) + echo "Unknown parameter passed: $1" + exit 1 + ;; + esac +done + +# Get the module paths +module_paths=$(${GREP_COMMAND} -oP 'include\("\K[^"]+' settings.gradle.kts) + +# Ensure the output directory exists +mkdir -p docs/images/graphs/ + +# Function to check and create a README.md for modules which don't have one. +check_and_create_readme() { + local module_path="$1" + local file_name="$2" + + local readme_path="${module_path:1}" # Remove leading colon + readme_path=${readme_path//:/\/} # Replace colons with slashes + readme_path="${readme_path}/README.md" #Append the filename + + # Check if README.md exists and create it if not + if [[ ! -f "$readme_path" ]]; then + echo "Creating README.md for ${module_path}" + + # Determine the depth of the module based on the number of colons + local depth=$(awk -F: '{print NF-1}' <<< "${module_path}") + + # Construct the relative image path with the correct number of "../" + local relative_image_path="../" + for ((i=1; i<$depth; i++)); do + relative_image_path+="../" + done + relative_image_path+="docs/images/graphs/${file_name}.svg" + + echo "# ${module_path} module" > "$readme_path" + echo "## Dependency graph" >> "$readme_path" + echo "![Dependency graph](${relative_image_path})" >> "$readme_path" + fi +} + +# Loop through each module path +echo "$module_paths" | while read -r module_path; do + # Check if the module is in the excluded list + if [[ ! " ${excluded_modules[@]} " =~ " ${module_path} " ]]; then + # Derive the filename from the module path + file_name="dep_graph${module_path//:/_}" # Replace colons with underscores + file_name="${file_name//-/_}" # Replace dashes with underscores + + check_and_create_readme "$module_path" "$file_name" + + # Generate the .gv file in a temporary location + # NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail diff --git a/lint/README.md b/lint/README.md deleted file mode 100644 index 3eceb434b..000000000 --- a/lint/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# :lint module - -![Dependency graph](../docs/images/graphs/dep_graph_lint.png) diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index acb540c3b..cfda9d64d 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -14,6 +14,7 @@ * limitations under the License. */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -29,9 +30,9 @@ java { targetCompatibility = JavaVersion.VERSION_11 } -tasks.withType().configureEach { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 } } diff --git a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt index 4c9d55764..09af17db9 100644 --- a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt +++ b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt @@ -34,15 +34,13 @@ import org.jetbrains.uast.UQualifiedReferenceExpression */ class DesignSystemDetector : Detector(), Detector.UastScanner { - override fun getApplicableUastTypes(): List> { - return listOf( - UCallExpression::class.java, - UQualifiedReferenceExpression::class.java, - ) - } + override fun getApplicableUastTypes(): List> = listOf( + UCallExpression::class.java, + UQualifiedReferenceExpression::class.java, + ) - override fun createUastHandler(context: JavaContext): UElementHandler { - return object : UElementHandler() { + override fun createUastHandler(context: JavaContext): UElementHandler = + object : UElementHandler() { override fun visitCallExpression(node: UCallExpression) { val name = node.methodName ?: return val preferredName = METHOD_NAMES[name] ?: return @@ -55,7 +53,6 @@ class DesignSystemDetector : Detector(), Detector.UastScanner { reportIssue(context, node, name, preferredName) } } - } companion object { @JvmField diff --git a/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetectorTest.kt b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetectorTest.kt new file mode 100644 index 000000000..188a52ee0 --- /dev/null +++ b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetectorTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.lint.designsystem + +import com.android.tools.lint.checks.infrastructure.TestFile +import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector.Companion.ISSUE +import com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector.Companion.METHOD_NAMES +import com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector.Companion.RECEIVER_NAMES +import org.junit.Test + +class DesignSystemDetectorTest { + + @Test + fun `detect replacements of Composable`() { + lint() + .issues(ISSUE) + .allowMissingSdk() + .files( + COMPOSABLE_STUB, + STUBS, + @Suppress("LintImplTrimIndent") + kotlin( + """ + |import androidx.compose.runtime.Composable + | + |@Composable + |fun App() { + ${METHOD_NAMES.keys.joinToString("\n") { "| $it()" }} + |} + """.trimMargin(), + ).indented(), + ) + .run() + .expect( + """ + src/test.kt:5: Error: Using MaterialTheme instead of NiaTheme [DesignSystem] + MaterialTheme() + ~~~~~~~~~~~~~~~ + src/test.kt:6: Error: Using Button instead of NiaButton [DesignSystem] + Button() + ~~~~~~~~ + src/test.kt:7: Error: Using OutlinedButton instead of NiaOutlinedButton [DesignSystem] + OutlinedButton() + ~~~~~~~~~~~~~~~~ + src/test.kt:8: Error: Using TextButton instead of NiaTextButton [DesignSystem] + TextButton() + ~~~~~~~~~~~~ + src/test.kt:9: Error: Using FilterChip instead of NiaFilterChip [DesignSystem] + FilterChip() + ~~~~~~~~~~~~ + src/test.kt:10: Error: Using ElevatedFilterChip instead of NiaFilterChip [DesignSystem] + ElevatedFilterChip() + ~~~~~~~~~~~~~~~~~~~~ + src/test.kt:11: Error: Using NavigationBar instead of NiaNavigationBar [DesignSystem] + NavigationBar() + ~~~~~~~~~~~~~~~ + src/test.kt:12: Error: Using NavigationBarItem instead of NiaNavigationBarItem [DesignSystem] + NavigationBarItem() + ~~~~~~~~~~~~~~~~~~~ + src/test.kt:13: Error: Using NavigationRail instead of NiaNavigationRail [DesignSystem] + NavigationRail() + ~~~~~~~~~~~~~~~~ + src/test.kt:14: Error: Using NavigationRailItem instead of NiaNavigationRailItem [DesignSystem] + NavigationRailItem() + ~~~~~~~~~~~~~~~~~~~~ + src/test.kt:15: Error: Using TabRow instead of NiaTabRow [DesignSystem] + TabRow() + ~~~~~~~~ + src/test.kt:16: Error: Using Tab instead of NiaTab [DesignSystem] + Tab() + ~~~~~ + src/test.kt:17: Error: Using IconToggleButton instead of NiaIconToggleButton [DesignSystem] + IconToggleButton() + ~~~~~~~~~~~~~~~~~~ + src/test.kt:18: Error: Using FilledIconToggleButton instead of NiaIconToggleButton [DesignSystem] + FilledIconToggleButton() + ~~~~~~~~~~~~~~~~~~~~~~~~ + src/test.kt:19: Error: Using FilledTonalIconToggleButton instead of NiaIconToggleButton [DesignSystem] + FilledTonalIconToggleButton() + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + src/test.kt:20: Error: Using OutlinedIconToggleButton instead of NiaIconToggleButton [DesignSystem] + OutlinedIconToggleButton() + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + src/test.kt:21: Error: Using CenterAlignedTopAppBar instead of NiaTopAppBar [DesignSystem] + CenterAlignedTopAppBar() + ~~~~~~~~~~~~~~~~~~~~~~~~ + src/test.kt:22: Error: Using SmallTopAppBar instead of NiaTopAppBar [DesignSystem] + SmallTopAppBar() + ~~~~~~~~~~~~~~~~ + src/test.kt:23: Error: Using MediumTopAppBar instead of NiaTopAppBar [DesignSystem] + MediumTopAppBar() + ~~~~~~~~~~~~~~~~~ + src/test.kt:24: Error: Using LargeTopAppBar instead of NiaTopAppBar [DesignSystem] + LargeTopAppBar() + ~~~~~~~~~~~~~~~~ + 20 errors, 0 warnings + """.trimIndent(), + ) + } + + @Test + fun `detect replacements of Receiver`() { + lint() + .issues(ISSUE) + .allowMissingSdk() + .files( + COMPOSABLE_STUB, + STUBS, + @Suppress("LintImplTrimIndent") + kotlin( + """ + |fun main() { + ${RECEIVER_NAMES.keys.joinToString("\n") { "| $it.toString()" }} + |} + """.trimMargin(), + ).indented(), + ) + .run() + .expect( + """ + src/test.kt:2: Error: Using Icons instead of NiaIcons [DesignSystem] + Icons.toString() + ~~~~~~~~~~~~~~~~ + 1 errors, 0 warnings + """.trimIndent(), + ) + } + + private companion object { + + private val COMPOSABLE_STUB: TestFile = kotlin( + """ + package androidx.compose.runtime + annotation class Composable + """.trimIndent(), + ).indented() + + private val STUBS: TestFile = kotlin( + """ + |import androidx.compose.runtime.Composable + | + ${METHOD_NAMES.keys.joinToString("\n") { "|@Composable fun $it() = {}" }} + ${RECEIVER_NAMES.keys.joinToString("\n") { "|object $it" }} + | + """.trimMargin(), + ).indented() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8a72d695a..c90c1a24a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,7 +24,7 @@ pluginManagement { } dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS repositories { google() mavenCentral() @@ -36,20 +36,22 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") include(":app-nia-catalog") include(":benchmarks") +include(":core:analytics") include(":core:common") include(":core:data") include(":core:data-test") include(":core:database") include(":core:datastore") +include(":core:datastore-proto") include(":core:datastore-test") include(":core:designsystem") include(":core:domain") include(":core:model") include(":core:network") -include(":core:ui") -include(":core:testing") -include(":core:analytics") include(":core:notifications") +include(":core:screenshot-testing") +include(":core:testing") +include(":core:ui") include(":feature:foryou") include(":feature:interests") diff --git a/sync/README.md b/sync/README.md deleted file mode 100644 index b100e27ad..000000000 --- a/sync/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# :sync module - -![Dependency graph](../docs/images/graphs/dep_graph_sync.png) diff --git a/sync/sync-test/README.md b/sync/sync-test/README.md new file mode 100644 index 000000000..78876290f --- /dev/null +++ b/sync/sync-test/README.md @@ -0,0 +1,3 @@ +# :sync:sync-test module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_sync_sync_test.svg) diff --git a/sync/sync-test/build.gradle.kts b/sync/sync-test/build.gradle.kts index 02e573ae5..0fac2cf83 100644 --- a/sync/sync-test/build.gradle.kts +++ b/sync/sync-test/build.gradle.kts @@ -23,7 +23,7 @@ android { } dependencies { - api(projects.sync.work) + implementation(libs.hilt.android.testing) implementation(projects.core.data) - implementation(projects.core.testing) + implementation(projects.sync.work) } diff --git a/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt b/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt index 2b0b4fb6a..c13b409e6 100644 --- a/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt +++ b/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import javax.inject.Inject -class NeverSyncingSyncManager @Inject constructor() : SyncManager { +internal class NeverSyncingSyncManager @Inject constructor() : SyncManager { override val isSyncing: Flow = flowOf(false) override fun requestSync() = Unit } diff --git a/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt b/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt index 0089450b5..ceca1cb5c 100644 --- a/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt +++ b/sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt @@ -18,6 +18,8 @@ package com.google.samples.apps.nowinandroid.core.sync.test import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.sync.di.SyncModule +import com.google.samples.apps.nowinandroid.sync.status.StubSyncSubscriber +import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber import dagger.Binds import dagger.Module import dagger.hilt.components.SingletonComponent @@ -28,9 +30,14 @@ import dagger.hilt.testing.TestInstallIn components = [SingletonComponent::class], replaces = [SyncModule::class], ) -interface TestSyncModule { +internal interface TestSyncModule { @Binds fun bindsSyncStatusMonitor( syncStatusMonitor: NeverSyncingSyncManager, ): SyncManager + + @Binds + fun bindsSyncSubscriber( + syncSubscriber: StubSyncSubscriber, + ): SyncSubscriber } diff --git a/sync/work/README.md b/sync/work/README.md new file mode 100644 index 000000000..2fe66d616 --- /dev/null +++ b/sync/work/README.md @@ -0,0 +1,3 @@ +# :sync:work module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_sync_work.svg) diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index 5eec3bb15..7e61c7389 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -27,23 +27,19 @@ android { } dependencies { - implementation(projects.core.analytics) - implementation(projects.core.common) - implementation(projects.core.data) - implementation(projects.core.datastore) - implementation(projects.core.model) - implementation(libs.androidx.lifecycle.livedata.ktx) + ksp(libs.hilt.ext.compiler) + implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.work.ktx) implementation(libs.hilt.ext.work) - implementation(libs.kotlinx.coroutines.android) + implementation(projects.core.analytics) + implementation(projects.core.data) prodImplementation(libs.firebase.cloud.messaging) + prodImplementation(platform(libs.firebase.bom)) - kapt(libs.hilt.ext.compiler) - - testImplementation(projects.core.testing) - - androidTestImplementation(projects.core.testing) androidTestImplementation(libs.androidx.work.testing) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.kotlinx.coroutines.guava) + androidTestImplementation(projects.core.testing) } diff --git a/sync/work/src/demo/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/demo/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt index 40d094cd2..91ef476f6 100644 --- a/sync/work/src/demo/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt +++ b/sync/work/src/demo/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -27,14 +27,14 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -interface SyncModule { +abstract class SyncModule { @Binds - fun bindsSyncStatusMonitor( + internal abstract fun bindsSyncStatusMonitor( syncStatusMonitor: WorkManagerSyncManager, ): SyncManager @Binds - fun bindsSyncSubscriber( + internal abstract fun bindsSyncSubscriber( syncSubscriber: StubSyncSubscriber, ): SyncSubscriber } diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt index 00f61f17d..0a631534b 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt @@ -28,7 +28,7 @@ object Sync { WorkManager.getInstance(context).apply { // Run sync on app startup and ensure only one sync worker runs at any time enqueueUniqueWork( - SyncWorkName, + SYNC_WORK_NAME, ExistingWorkPolicy.KEEP, SyncWorker.startUpSyncWork(), ) @@ -37,4 +37,4 @@ object Sync { } // This name should not be changed otherwise the app may have concurrent sync requests running -internal const val SyncWorkName = "SyncWorkName" +internal const val SYNC_WORK_NAME = "SyncWorkName" diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt index 5abf2eee5..843510aaf 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt @@ -54,10 +54,10 @@ private fun Context.syncWorkNotification(): Notification { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( SYNC_NOTIFICATION_CHANNEL_ID, - getString(R.string.sync_notification_channel_name), + getString(R.string.sync_work_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT, ).apply { - description = getString(R.string.sync_notification_channel_description) + description = getString(R.string.sync_work_notification_channel_description) } // Register the channel with the system val notificationManager: NotificationManager? = @@ -71,9 +71,9 @@ private fun Context.syncWorkNotification(): Notification { SYNC_NOTIFICATION_CHANNEL_ID, ) .setSmallIcon( - com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, + com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, ) - .setContentTitle(getString(R.string.sync_notification_title)) + .setContentTitle(getString(R.string.sync_work_notification_title)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .build() } diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt index 9131e4888..d4b6e0df6 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt @@ -22,7 +22,7 @@ import androidx.work.WorkInfo import androidx.work.WorkInfo.State import androidx.work.WorkManager import com.google.samples.apps.nowinandroid.core.data.util.SyncManager -import com.google.samples.apps.nowinandroid.sync.initializers.SyncWorkName +import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_WORK_NAME import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -33,11 +33,11 @@ import javax.inject.Inject /** * [SyncManager] backed by [WorkInfo] from [WorkManager] */ -class WorkManagerSyncManager @Inject constructor( +internal class WorkManagerSyncManager @Inject constructor( @ApplicationContext private val context: Context, ) : SyncManager { override val isSyncing: Flow = - WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(SyncWorkName) + WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(SYNC_WORK_NAME) .map(List::anyRunning) .conflate() @@ -45,7 +45,7 @@ class WorkManagerSyncManager @Inject constructor( val workManager = WorkManager.getInstance(context) // Run sync on app startup and ensure only one sync worker runs at any time workManager.enqueueUniqueWork( - SyncWorkName, + SYNC_WORK_NAME, ExistingWorkPolicy.KEEP, SyncWorker.startUpSyncWork(), ) diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt index d5250b330..f2d9283c0 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt @@ -19,12 +19,12 @@ package com.google.samples.apps.nowinandroid.sync.workers import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper -fun AnalyticsHelper.logSyncStarted() = +internal fun AnalyticsHelper.logSyncStarted() = logEvent( AnalyticsEvent(type = "network_sync_started"), ) -fun AnalyticsHelper.logSyncFinished(syncedSuccessfully: Boolean) { +internal fun AnalyticsHelper.logSyncFinished(syncedSuccessfully: Boolean) { val eventType = if (syncedSuccessfully) "network_sync_successful" else "network_sync_failed" logEvent( AnalyticsEvent(type = eventType), diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index 1948b49a3..ea5f36042 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -48,7 +48,7 @@ import kotlinx.coroutines.withContext * sync functionality. */ @HiltWorker -class SyncWorker @AssistedInject constructor( +internal class SyncWorker @AssistedInject constructor( @Assisted private val appContext: Context, @Assisted workerParams: WorkerParameters, private val niaPreferences: NiaPreferencesDataSource, diff --git a/sync/work/src/main/res/values/strings.xml b/sync/work/src/main/res/values/strings.xml index e3fd73ff8..4d77f6a7b 100644 --- a/sync/work/src/main/res/values/strings.xml +++ b/sync/work/src/main/res/values/strings.xml @@ -15,8 +15,8 @@ limitations under the License. --> - Now in Android - Sync - Background tasks for Now in Android + Now in Android + Sync + Background tasks for Now in Android diff --git a/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt index 9a69a3c78..ceeb39548 100644 --- a/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt +++ b/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -32,20 +32,20 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -interface SyncModule { +abstract class SyncModule { @Binds - fun bindsSyncStatusMonitor( + internal abstract fun bindsSyncStatusMonitor( syncStatusMonitor: WorkManagerSyncManager, ): SyncManager @Binds - fun bindsSyncSubscriber( + internal abstract fun bindsSyncSubscriber( syncSubscriber: FirebaseSyncSubscriber, ): SyncSubscriber companion object { @Provides @Singleton - fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging + internal fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging } } diff --git a/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt index e51e30164..c7297dd1a 100644 --- a/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt +++ b/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt @@ -25,7 +25,7 @@ import javax.inject.Inject private const val SYNC_TOPIC_SENDER = "/topics/sync" @AndroidEntryPoint -class SyncNotificationsService : FirebaseMessagingService() { +internal class SyncNotificationsService : FirebaseMessagingService() { @Inject lateinit var syncManager: SyncManager diff --git a/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt b/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt index c2405bccc..2c48488e6 100644 --- a/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt +++ b/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt @@ -24,7 +24,7 @@ import javax.inject.Inject /** * Implementation of [SyncSubscriber] that subscribes to the FCM [SYNC_TOPIC] */ -class FirebaseSyncSubscriber @Inject constructor( +internal class FirebaseSyncSubscriber @Inject constructor( private val firebaseMessaging: FirebaseMessaging, ) : SyncSubscriber { override suspend fun subscribe() { diff --git a/tools/setup.sh b/tools/setup.sh index 1467bbad0..b0f204268 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -35,7 +35,7 @@ cp "${GIT_ROOT}/tools/pre-push" "${GIT_DIR}/hooks/pre-push" \ cat <<-EOF Checking the following settings helps avoid miscellaneous issues: * Settings -> Editor -> General -> Remove trailing spaces on: Modified lines - * Settings -> Editor -> General -> Ensure every file ends with a line break + * Settings -> Editor -> General -> Ensure every saved file ends with a line break * Settings -> Editor -> General -> Auto Import -> Optimize imports on the fly (for both Kotlin\ and Java) EOF