Migrate project to Jetpack Navigation 3

Migrate project to Jetpack Navigation 3
pull/2011/head
Don Turner 1 week ago committed by GitHub
commit 161181f768
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -77,27 +77,28 @@ jobs:
disable_globbing: true disable_globbing: true
commit_message: "🤖 Updates baselines for Dependency Guard" commit_message: "🤖 Updates baselines for Dependency Guard"
- name: Update Graphs # See https://github.com/android/nowinandroid/issues/2005
run: ./gradlew graphUpdate # - name: Update Graphs
# run: ./gradlew graphUpdate
- name: Check Graphs #
id: graphs_verify # - name: Check Graphs
run: git add -- '**/README.md' && git diff --cached --quiet --exit-code -- '**/README.md' # id: graphs_verify
# run: git add -- "**/README.md" && git diff --cached --quiet --exit-code -- "**/README.md"
- name: Prevent updating graphs if this is a fork #
id: checkfork_graphs # - name: Prevent updating graphs if this is a fork
continue-on-error: false # id: checkfork_graphs
if: steps.graphs_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository # continue-on-error: false
run: | # if: steps.graphs_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
echo "::error::Check Graphs failed, please update graphs with: ./gradlew graphUpdate" && exit 1 # run: |
# echo "::error::Check Graphs failed, please update graphs with: ./gradlew graphUpdate" && exit 1
- name: Push new graphs if available #
if: steps.graphs_verify.outcome == 'failure' && github.event_name == 'pull_request' # - name: Push new graphs if available
uses: stefanzweifel/git-auto-commit-action@v5 # if: steps.graphs_verify.outcome == 'failure' && github.event_name == 'pull_request'
with: # uses: stefanzweifel/git-auto-commit-action@v5
file_pattern: '**/README.md' # with:
disable_globbing: true # file_pattern: '**/README.md'
commit_message: "🤖 Updates graphs" # disable_globbing: true
# commit_message: "🤖 Updates graphs"
- name: Run all local screenshot tests (Roborazzi) - name: Run all local screenshot tests (Roborazzi)
id: screenshotsverify id: screenshotsverify

@ -12,47 +12,49 @@ androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.5.0 androidx.collection:collection-jvm:1.5.0
androidx.collection:collection-ktx:1.5.0 androidx.collection:collection-ktx:1.5.0
androidx.collection:collection:1.5.0 androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.10.0-alpha02 androidx.compose.animation:animation-android:1.10.0-alpha04
androidx.compose.animation:animation-core-android:1.10.0-alpha02 androidx.compose.animation:animation-core-android:1.10.0-alpha04
androidx.compose.animation:animation-core:1.10.0-alpha02 androidx.compose.animation:animation-core:1.10.0-alpha04
androidx.compose.animation:animation:1.10.0-alpha02 androidx.compose.animation:animation:1.10.0-alpha04
androidx.compose.foundation:foundation-android:1.10.0-alpha02 androidx.compose.foundation:foundation-android:1.10.0-alpha04
androidx.compose.foundation:foundation-layout-android:1.10.0-alpha02 androidx.compose.foundation:foundation-layout-android:1.10.0-alpha04
androidx.compose.foundation:foundation-layout:1.10.0-alpha02 androidx.compose.foundation:foundation-layout:1.10.0-alpha04
androidx.compose.foundation:foundation:1.10.0-alpha02 androidx.compose.foundation:foundation:1.10.0-alpha04
androidx.compose.material3.adaptive:adaptive-android:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive-android:1.2.0-beta03
androidx.compose.material3.adaptive:adaptive:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive:1.2.0-beta03
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha03 androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha03 androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04
androidx.compose.material3:material3-android:1.5.0-alpha03 androidx.compose.material3:material3-android:1.5.0-alpha04
androidx.compose.material3:material3:1.5.0-alpha03 androidx.compose.material3:material3:1.5.0-alpha04
androidx.compose.material:material-icons-core-android:1.7.8 androidx.compose.material:material-icons-core-android:1.7.8
androidx.compose.material:material-icons-core:1.7.8 androidx.compose.material:material-icons-core:1.7.8
androidx.compose.material:material-icons-extended-android:1.7.8 androidx.compose.material:material-icons-extended-android:1.7.8
androidx.compose.material:material-icons-extended:1.7.8 androidx.compose.material:material-icons-extended:1.7.8
androidx.compose.material:material-ripple-android:1.10.0-alpha02 androidx.compose.material:material-ripple-android:1.10.0-alpha04
androidx.compose.material:material-ripple:1.10.0-alpha02 androidx.compose.material:material-ripple:1.10.0-alpha04
androidx.compose.runtime:runtime-android:1.10.0-alpha02 androidx.compose.runtime:runtime-android:1.10.0-alpha04
androidx.compose.runtime:runtime-annotation-android:1.10.0-alpha02 androidx.compose.runtime:runtime-annotation-android:1.10.0-alpha04
androidx.compose.runtime:runtime-annotation:1.10.0-alpha02 androidx.compose.runtime:runtime-annotation:1.10.0-alpha04
androidx.compose.runtime:runtime-saveable-android:1.10.0-alpha02 androidx.compose.runtime:runtime-retain-android:1.10.0-alpha04
androidx.compose.runtime:runtime-saveable:1.10.0-alpha02 androidx.compose.runtime:runtime-retain:1.10.0-alpha04
androidx.compose.runtime:runtime:1.10.0-alpha02 androidx.compose.runtime:runtime-saveable-android:1.10.0-alpha04
androidx.compose.ui:ui-android:1.10.0-alpha02 androidx.compose.runtime:runtime-saveable:1.10.0-alpha04
androidx.compose.ui:ui-geometry-android:1.10.0-alpha02 androidx.compose.runtime:runtime:1.10.0-alpha04
androidx.compose.ui:ui-geometry:1.10.0-alpha02 androidx.compose.ui:ui-android:1.10.0-alpha04
androidx.compose.ui:ui-graphics-android:1.10.0-alpha02 androidx.compose.ui:ui-geometry-android:1.10.0-alpha04
androidx.compose.ui:ui-graphics:1.10.0-alpha02 androidx.compose.ui:ui-geometry:1.10.0-alpha04
androidx.compose.ui:ui-text-android:1.10.0-alpha02 androidx.compose.ui:ui-graphics-android:1.10.0-alpha04
androidx.compose.ui:ui-text:1.10.0-alpha02 androidx.compose.ui:ui-graphics:1.10.0-alpha04
androidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha02 androidx.compose.ui:ui-text-android:1.10.0-alpha04
androidx.compose.ui:ui-tooling-preview:1.10.0-alpha02 androidx.compose.ui:ui-text:1.10.0-alpha04
androidx.compose.ui:ui-unit-android:1.10.0-alpha02 androidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha04
androidx.compose.ui:ui-unit:1.10.0-alpha02 androidx.compose.ui:ui-tooling-preview:1.10.0-alpha04
androidx.compose.ui:ui-util-android:1.10.0-alpha02 androidx.compose.ui:ui-unit-android:1.10.0-alpha04
androidx.compose.ui:ui-util:1.10.0-alpha02 androidx.compose.ui:ui-unit:1.10.0-alpha04
androidx.compose.ui:ui:1.10.0-alpha02 androidx.compose.ui:ui-util-android:1.10.0-alpha04
androidx.compose:compose-bom-alpha:2025.08.01 androidx.compose.ui:ui-util:1.10.0-alpha04
androidx.compose.ui:ui:1.10.0-alpha04
androidx.compose:compose-bom-alpha:2025.09.01
androidx.concurrent:concurrent-futures:1.1.0 androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.16.0 androidx.core:core-ktx:1.16.0
androidx.core:core-viewtree:1.0.0 androidx.core:core-viewtree:1.0.0
@ -69,34 +71,34 @@ androidx.graphics:graphics-shapes-android:1.0.1
androidx.graphics:graphics-shapes:1.0.1 androidx.graphics:graphics-shapes:1.0.1
androidx.interpolator:interpolator:1.0.0 androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.10.0-alpha03 androidx.lifecycle:lifecycle-common-java8:2.9.4
androidx.lifecycle:lifecycle-common-jvm:2.10.0-alpha03 androidx.lifecycle:lifecycle-common-jvm:2.9.4
androidx.lifecycle:lifecycle-common:2.10.0-alpha03 androidx.lifecycle:lifecycle-common:2.9.4
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0-alpha03 androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4
androidx.lifecycle:lifecycle-livedata-core:2.10.0-alpha03 androidx.lifecycle:lifecycle-livedata-core:2.9.4
androidx.lifecycle:lifecycle-livedata:2.10.0-alpha03 androidx.lifecycle:lifecycle-livedata:2.9.4
androidx.lifecycle:lifecycle-process:2.10.0-alpha03 androidx.lifecycle:lifecycle-process:2.9.4
androidx.lifecycle:lifecycle-runtime-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-android:2.9.4
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4
androidx.lifecycle:lifecycle-runtime-compose:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-compose:2.9.4
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-ktx:2.9.4
androidx.lifecycle:lifecycle-runtime:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime:2.9.4
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-android:2.9.4
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4
androidx.lifecycle:lifecycle-viewmodel:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel:2.9.4
androidx.loader:loader:1.0.0 androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-beta01 androidx.metrics:metrics-performance:1.0.0-beta01
androidx.print:print:1.0.0 androidx.print:print:1.0.0
androidx.profileinstaller:profileinstaller:1.4.0 androidx.profileinstaller:profileinstaller:1.4.0
androidx.savedstate:savedstate-android:1.4.0-alpha03 androidx.savedstate:savedstate-android:1.3.2
androidx.savedstate:savedstate-compose-android:1.4.0-alpha03 androidx.savedstate:savedstate-compose-android:1.3.2
androidx.savedstate:savedstate-compose:1.4.0-alpha03 androidx.savedstate:savedstate-compose:1.3.2
androidx.savedstate:savedstate-ktx:1.4.0-alpha03 androidx.savedstate:savedstate-ktx:1.3.2
androidx.savedstate:savedstate:1.4.0-alpha03 androidx.savedstate:savedstate:1.3.2
androidx.startup:startup-runtime:1.1.1 androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.3.0-alpha02 androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing:1.3.0-alpha02 androidx.tracing:tracing:1.3.0-alpha02
@ -128,10 +130,10 @@ org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.2.21 org.jetbrains.kotlin:kotlin-stdlib:2.2.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1 org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.6.1 org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3

@ -11,6 +11,42 @@ config:
nodePlacementStrategy: SIMPLE nodePlacementStrategy: SIMPLE
--- ---
graph TB graph TB
subgraph :feature
direction TB
subgraph :feature:settings
direction TB
:feature:settings:impl[impl]:::android-library
end
subgraph :feature:foryou
direction TB
:feature:foryou:api[api]:::android-library
:feature:foryou:impl[impl]:::android-library
end
subgraph :feature:bookmarks
direction TB
:feature:bookmarks:api[api]:::android-library
:feature:bookmarks:impl[impl]:::android-library
end
subgraph :feature:search
direction TB
:feature:search:api[api]:::android-library
:feature:search:impl[impl]:::android-library
end
subgraph :feature:interests
direction TB
:feature:interests:api[api]:::android-library
:feature:interests:impl[impl]:::android-library
end
subgraph :feature:topic
direction TB
:feature:topic:api[api]:::android-library
:feature:topic:impl[impl]:::android-library
end
end
subgraph :sync
direction TB
:sync:work[work]:::android-library
end
subgraph :core subgraph :core
direction TB direction TB
:core:analytics[analytics]:::android-library :core:analytics[analytics]:::android-library
@ -22,23 +58,11 @@ graph TB
:core:designsystem[designsystem]:::android-library :core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library :core:domain[domain]:::android-library
:core:model[model]:::jvm-library :core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library :core:network[network]:::android-library
:core:notifications[notifications]:::android-library :core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library :core:ui[ui]:::android-library
end end
subgraph :feature
direction TB
:feature:bookmarks[bookmarks]:::android-feature
:feature:foryou[foryou]:::android-feature
:feature:interests[interests]:::android-feature
:feature:search[search]:::android-feature
:feature:settings[settings]:::android-feature
:feature:topic[topic]:::android-feature
end
subgraph :sync
direction TB
:sync:work[work]:::android-library
end
:benchmarks[benchmarks]:::android-test :benchmarks[benchmarks]:::android-test
:app[app]:::android-application :app[app]:::android-application
@ -49,12 +73,17 @@ graph TB
:app -.-> :core:designsystem :app -.-> :core:designsystem
:app -.-> :core:model :app -.-> :core:model
:app -.-> :core:ui :app -.-> :core:ui
:app -.-> :feature:bookmarks :app -.-> :feature:bookmarks:api
:app -.-> :feature:foryou :app -.-> :feature:bookmarks:impl
:app -.-> :feature:interests :app -.-> :feature:foryou:api
:app -.-> :feature:search :app -.-> :feature:foryou:impl
:app -.-> :feature:settings :app -.-> :feature:interests:api
:app -.-> :feature:topic :app -.-> :feature:interests:impl
:app -.-> :feature:search:api
:app -.-> :feature:search:impl
:app -.-> :feature:settings:impl
:app -.-> :feature:topic:api
:app -.-> :feature:topic:impl
:app -.-> :sync:work :app -.-> :sync:work
:benchmarks -.->|testedApks| :app :benchmarks -.->|testedApks| :app
:core:data -.-> :core:analytics :core:data -.-> :core:analytics
@ -76,28 +105,43 @@ graph TB
:core:ui --> :core:analytics :core:ui --> :core:analytics
:core:ui --> :core:designsystem :core:ui --> :core:designsystem
:core:ui --> :core:model :core:ui --> :core:model
:feature:bookmarks -.-> :core:data :feature:bookmarks:api --> :core:navigation
:feature:bookmarks -.-> :core:designsystem :feature:bookmarks:impl -.-> :core:data
:feature:bookmarks -.-> :core:ui :feature:bookmarks:impl -.-> :core:designsystem
:feature:foryou -.-> :core:data :feature:bookmarks:impl -.-> :core:ui
:feature:foryou -.-> :core:designsystem :feature:bookmarks:impl -.-> :feature:bookmarks:api
:feature:foryou -.-> :core:domain :feature:bookmarks:impl -.-> :feature:topic:api
:feature:foryou -.-> :core:notifications :feature:foryou:api --> :core:navigation
:feature:foryou -.-> :core:ui :feature:foryou:impl -.-> :core:designsystem
:feature:interests -.-> :core:data :feature:foryou:impl -.-> :core:domain
:feature:interests -.-> :core:designsystem :feature:foryou:impl -.-> :core:notifications
:feature:interests -.-> :core:domain :feature:foryou:impl -.-> :core:ui
:feature:interests -.-> :core:ui :feature:foryou:impl -.-> :feature:foryou:api
:feature:search -.-> :core:data :feature:foryou:impl -.-> :feature:topic:api
:feature:search -.-> :core:designsystem :feature:interests:api --> :core:navigation
:feature:search -.-> :core:domain :feature:interests:impl -.-> :core:designsystem
:feature:search -.-> :core:ui :feature:interests:impl -.-> :core:domain
:feature:settings -.-> :core:data :feature:interests:impl -.-> :core:ui
:feature:settings -.-> :core:designsystem :feature:interests:impl -.-> :feature:interests:api
:feature:settings -.-> :core:ui :feature:interests:impl -.-> :feature:topic:api
:feature:topic -.-> :core:data :feature:search:api -.-> :core:domain
:feature:topic -.-> :core:designsystem :feature:search:api --> :core:navigation
:feature:topic -.-> :core:ui :feature:search:impl -.-> :core:designsystem
:feature:search:impl -.-> :core:domain
:feature:search:impl -.-> :core:ui
:feature:search:impl -.-> :feature:interests:api
:feature:search:impl -.-> :feature:search:api
:feature:search:impl -.-> :feature:topic:api
:feature:settings:impl -.-> :core:data
:feature:settings:impl -.-> :core:designsystem
:feature:settings:impl -.-> :core:ui
:feature:topic:api -.-> :core:designsystem
:feature:topic:api --> :core:navigation
:feature:topic:api -.-> :core:ui
:feature:topic:impl -.-> :core:data
:feature:topic:impl -.-> :core:designsystem
:feature:topic:impl -.-> :core:ui
:feature:topic:impl -.-> :feature:topic:api
:sync:work -.-> :core:analytics :sync:work -.-> :core:analytics
:sync:work -.-> :core:data :sync:work -.-> :core:data
:sync:work -.-> :core:notifications :sync:work -.-> :core:notifications

@ -68,12 +68,17 @@ android {
} }
dependencies { dependencies {
implementation(projects.feature.interests) implementation(projects.feature.interests.api)
implementation(projects.feature.foryou) implementation(projects.feature.interests.impl)
implementation(projects.feature.bookmarks) implementation(projects.feature.foryou.api)
implementation(projects.feature.topic) implementation(projects.feature.foryou.impl)
implementation(projects.feature.search) implementation(projects.feature.bookmarks.api)
implementation(projects.feature.settings) implementation(projects.feature.bookmarks.impl)
implementation(projects.feature.topic.api)
implementation(projects.feature.topic.impl)
implementation(projects.feature.search.api)
implementation(projects.feature.search.impl)
implementation(projects.feature.settings.impl)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.ui) implementation(projects.core.ui)
@ -85,16 +90,17 @@ dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation) implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.adaptive.navigation3)
implementation(libs.androidx.compose.material3.windowSizeClass) implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.compose.runtime.tracing) implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.lifecycle.viewModel.navigation3)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.window.core) implementation(libs.androidx.window.core)

@ -1,6 +1,6 @@
androidx.activity:activity-compose:1.10.1 androidx.activity:activity-compose:1.12.0
androidx.activity:activity-ktx:1.10.1 androidx.activity:activity-ktx:1.12.0
androidx.activity:activity:1.10.1 androidx.activity:activity:1.12.0
androidx.annotation:annotation-experimental:1.5.1 androidx.annotation:annotation-experimental:1.5.1
androidx.annotation:annotation-jvm:1.9.1 androidx.annotation:annotation-jvm:1.9.1
androidx.annotation:annotation:1.9.1 androidx.annotation:annotation:1.9.1
@ -13,54 +13,58 @@ androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.5.0 androidx.collection:collection-jvm:1.5.0
androidx.collection:collection-ktx:1.5.0 androidx.collection:collection-ktx:1.5.0
androidx.collection:collection:1.5.0 androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.10.0-alpha02 androidx.compose.animation:animation-android:1.10.0-beta02
androidx.compose.animation:animation-core-android:1.10.0-alpha02 androidx.compose.animation:animation-core-android:1.10.0-beta02
androidx.compose.animation:animation-core:1.10.0-alpha02 androidx.compose.animation:animation-core:1.10.0-beta02
androidx.compose.animation:animation:1.10.0-alpha02 androidx.compose.animation:animation:1.10.0-beta02
androidx.compose.foundation:foundation-android:1.10.0-alpha02 androidx.compose.foundation:foundation-android:1.10.0-beta02
androidx.compose.foundation:foundation-layout-android:1.10.0-alpha02 androidx.compose.foundation:foundation-layout-android:1.10.0-beta02
androidx.compose.foundation:foundation-layout:1.10.0-alpha02 androidx.compose.foundation:foundation-layout:1.10.0-beta02
androidx.compose.foundation:foundation:1.10.0-alpha02 androidx.compose.foundation:foundation:1.10.0-beta02
androidx.compose.material3.adaptive:adaptive-android:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-layout-android:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-layout:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation-android:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive-navigation3-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive:1.2.0-beta01 androidx.compose.material3.adaptive:adaptive-navigation3:1.3.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha03 androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha03 androidx.compose.material3.adaptive:adaptive:1.3.0-alpha04
androidx.compose.material3:material3-android:1.5.0-alpha03 androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04
androidx.compose.material3:material3-window-size-class-android:1.5.0-alpha03 androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04
androidx.compose.material3:material3-window-size-class:1.5.0-alpha03 androidx.compose.material3:material3-android:1.5.0-alpha04
androidx.compose.material3:material3:1.5.0-alpha03 androidx.compose.material3:material3-window-size-class-android:1.5.0-alpha04
androidx.compose.material3:material3-window-size-class:1.5.0-alpha04
androidx.compose.material3:material3:1.5.0-alpha04
androidx.compose.material:material-icons-core-android:1.7.8 androidx.compose.material:material-icons-core-android:1.7.8
androidx.compose.material:material-icons-core:1.7.8 androidx.compose.material:material-icons-core:1.7.8
androidx.compose.material:material-icons-extended-android:1.7.8 androidx.compose.material:material-icons-extended-android:1.7.8
androidx.compose.material:material-icons-extended:1.7.8 androidx.compose.material:material-icons-extended:1.7.8
androidx.compose.material:material-ripple-android:1.10.0-alpha02 androidx.compose.material:material-ripple-android:1.10.0-alpha04
androidx.compose.material:material-ripple:1.10.0-alpha02 androidx.compose.material:material-ripple:1.10.0-alpha04
androidx.compose.runtime:runtime-android:1.10.0-alpha02 androidx.compose.runtime:runtime-android:1.10.0-beta02
androidx.compose.runtime:runtime-annotation-android:1.10.0-alpha02 androidx.compose.runtime:runtime-annotation-android:1.10.0-beta02
androidx.compose.runtime:runtime-annotation:1.10.0-alpha02 androidx.compose.runtime:runtime-annotation:1.10.0-beta02
androidx.compose.runtime:runtime-saveable-android:1.10.0-alpha02 androidx.compose.runtime:runtime-retain-android:1.10.0-beta02
androidx.compose.runtime:runtime-saveable:1.10.0-alpha02 androidx.compose.runtime:runtime-retain:1.10.0-beta02
androidx.compose.runtime:runtime-tracing:1.10.0-alpha02 androidx.compose.runtime:runtime-saveable-android:1.10.0-beta02
androidx.compose.runtime:runtime:1.10.0-alpha02 androidx.compose.runtime:runtime-saveable:1.10.0-beta02
androidx.compose.ui:ui-android:1.10.0-alpha02 androidx.compose.runtime:runtime-tracing:1.10.0-beta02
androidx.compose.ui:ui-geometry-android:1.10.0-alpha02 androidx.compose.runtime:runtime:1.10.0-beta02
androidx.compose.ui:ui-geometry:1.10.0-alpha02 androidx.compose.ui:ui-android:1.10.0-beta02
androidx.compose.ui:ui-graphics-android:1.10.0-alpha02 androidx.compose.ui:ui-geometry-android:1.10.0-beta02
androidx.compose.ui:ui-graphics:1.10.0-alpha02 androidx.compose.ui:ui-geometry:1.10.0-beta02
androidx.compose.ui:ui-text-android:1.10.0-alpha02 androidx.compose.ui:ui-graphics-android:1.10.0-beta02
androidx.compose.ui:ui-text:1.10.0-alpha02 androidx.compose.ui:ui-graphics:1.10.0-beta02
androidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha02 androidx.compose.ui:ui-text-android:1.10.0-beta02
androidx.compose.ui:ui-tooling-preview:1.10.0-alpha02 androidx.compose.ui:ui-text:1.10.0-beta02
androidx.compose.ui:ui-unit-android:1.10.0-alpha02 androidx.compose.ui:ui-tooling-preview-android:1.10.0-beta02
androidx.compose.ui:ui-unit:1.10.0-alpha02 androidx.compose.ui:ui-tooling-preview:1.10.0-beta02
androidx.compose.ui:ui-util-android:1.10.0-alpha02 androidx.compose.ui:ui-unit-android:1.10.0-beta02
androidx.compose.ui:ui-util:1.10.0-alpha02 androidx.compose.ui:ui-unit:1.10.0-beta02
androidx.compose.ui:ui:1.10.0-alpha02 androidx.compose.ui:ui-util-android:1.10.0-beta02
androidx.compose:compose-bom-alpha:2025.08.01 androidx.compose.ui:ui-util:1.10.0-beta02
androidx.compose.ui:ui:1.10.0-beta02
androidx.compose:compose-bom-alpha:2025.09.01
androidx.concurrent:concurrent-futures-ktx:1.1.0 androidx.concurrent:concurrent-futures-ktx:1.1.0
androidx.concurrent:concurrent-futures:1.1.0 androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.16.0 androidx.core:core-ktx:1.16.0
@ -70,16 +74,18 @@ androidx.core:core:1.16.0
androidx.cursoradapter:cursoradapter:1.0.0 androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0 androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0 androidx.customview:customview:1.0.0
androidx.datastore:datastore-android:1.1.1 androidx.datastore:datastore-android:1.2.0
androidx.datastore:datastore-core-android:1.1.1 androidx.datastore:datastore-core-android:1.2.0
androidx.datastore:datastore-core-okio-jvm:1.1.1 androidx.datastore:datastore-core-okio-jvm:1.2.0
androidx.datastore:datastore-core-okio:1.1.1 androidx.datastore:datastore-core-okio:1.2.0
androidx.datastore:datastore-core:1.1.1 androidx.datastore:datastore-core:1.2.0
androidx.datastore:datastore-preferences-android:1.1.1 androidx.datastore:datastore-preferences-android:1.2.0
androidx.datastore:datastore-preferences-core-jvm:1.1.1 androidx.datastore:datastore-preferences-core-android:1.2.0
androidx.datastore:datastore-preferences-core:1.1.1 androidx.datastore:datastore-preferences-core:1.2.0
androidx.datastore:datastore-preferences:1.1.1 androidx.datastore:datastore-preferences-external-protobuf:1.2.0
androidx.datastore:datastore:1.1.1 androidx.datastore:datastore-preferences-proto:1.2.0
androidx.datastore:datastore-preferences:1.2.0
androidx.datastore:datastore:1.2.0
androidx.documentfile:documentfile:1.0.0 androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.0.0 androidx.drawerlayout:drawerlayout:1.0.0
androidx.dynamicanimation:dynamicanimation:1.0.0 androidx.dynamicanimation:dynamicanimation:1.0.0
@ -91,40 +97,45 @@ androidx.graphics:graphics-path:1.0.1
androidx.graphics:graphics-shapes-android:1.0.1 androidx.graphics:graphics-shapes-android:1.0.1
androidx.graphics:graphics-shapes:1.0.1 androidx.graphics:graphics-shapes:1.0.1
androidx.hilt:hilt-common:1.2.0 androidx.hilt:hilt-common:1.2.0
androidx.hilt:hilt-navigation-compose:1.2.0 androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0-alpha02
androidx.hilt:hilt-navigation:1.2.0 androidx.hilt:hilt-lifecycle-viewmodel:1.3.0-alpha02
androidx.hilt:hilt-work:1.2.0 androidx.hilt:hilt-work:1.2.0
androidx.interpolator:interpolator:1.0.0 androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.10.0-alpha03 androidx.lifecycle:lifecycle-common-java8:2.10.0
androidx.lifecycle:lifecycle-common-jvm:2.10.0-alpha03 androidx.lifecycle:lifecycle-common-jvm:2.10.0
androidx.lifecycle:lifecycle-common:2.10.0-alpha03 androidx.lifecycle:lifecycle-common:2.10.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0-alpha03 androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0
androidx.lifecycle:lifecycle-livedata-core:2.10.0-alpha03 androidx.lifecycle:lifecycle-livedata-core:2.10.0
androidx.lifecycle:lifecycle-livedata:2.10.0-alpha03 androidx.lifecycle:lifecycle-livedata:2.10.0
androidx.lifecycle:lifecycle-process:2.10.0-alpha03 androidx.lifecycle:lifecycle-process:2.10.0
androidx.lifecycle:lifecycle-runtime-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-compose:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
androidx.lifecycle:lifecycle-runtime:2.10.0-alpha03 androidx.lifecycle:lifecycle-runtime:2.10.0
androidx.lifecycle:lifecycle-service:2.10.0-alpha03 androidx.lifecycle:lifecycle-service:2.10.0
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-navigation3-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0
androidx.lifecycle:lifecycle-viewmodel:2.10.0-alpha03 androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0
androidx.lifecycle:lifecycle-viewmodel:2.10.0
androidx.loader:loader:1.0.0 androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-beta01 androidx.metrics:metrics-performance:1.0.0-beta01
androidx.navigation:navigation-common-ktx:2.8.5 androidx.navigation3:navigation3-runtime-android:1.0.0
androidx.navigation:navigation-common:2.8.5 androidx.navigation3:navigation3-runtime:1.0.0
androidx.navigation:navigation-compose:2.8.5 androidx.navigation3:navigation3-ui-android:1.0.0
androidx.navigation:navigation-runtime-ktx:2.8.5 androidx.navigation3:navigation3-ui:1.0.0
androidx.navigation:navigation-runtime:2.8.5 androidx.navigationevent:navigationevent-android:1.0.0
androidx.navigationevent:navigationevent-compose-android:1.0.0
androidx.navigationevent:navigationevent-compose:1.0.0
androidx.navigationevent:navigationevent:1.0.0
androidx.print:print:1.0.0 androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05 androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05 androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
@ -135,11 +146,11 @@ androidx.room:room-common:2.8.3
androidx.room:room-ktx:2.8.3 androidx.room:room-ktx:2.8.3
androidx.room:room-runtime-android:2.8.3 androidx.room:room-runtime-android:2.8.3
androidx.room:room-runtime:2.8.3 androidx.room:room-runtime:2.8.3
androidx.savedstate:savedstate-android:1.4.0-alpha03 androidx.savedstate:savedstate-android:1.4.0
androidx.savedstate:savedstate-compose-android:1.4.0-alpha03 androidx.savedstate:savedstate-compose-android:1.4.0
androidx.savedstate:savedstate-compose:1.4.0-alpha03 androidx.savedstate:savedstate-compose:1.4.0
androidx.savedstate:savedstate-ktx:1.4.0-alpha03 androidx.savedstate:savedstate-ktx:1.4.0
androidx.savedstate:savedstate:1.4.0-alpha03 androidx.savedstate:savedstate:1.4.0
androidx.sqlite:sqlite-android:2.6.1 androidx.sqlite:sqlite-android:2.6.1
androidx.sqlite:sqlite-framework-android:2.6.1 androidx.sqlite:sqlite-framework-android:2.6.1
androidx.sqlite:sqlite-framework:2.6.1 androidx.sqlite:sqlite-framework:2.6.1
@ -153,9 +164,9 @@ androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0 androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0 androidx.viewpager:viewpager:1.0.0
androidx.window:window-core-android:1.4.0 androidx.window:window-core-android:1.5.0
androidx.window:window-core:1.4.0 androidx.window:window-core:1.5.0
androidx.window:window:1.4.0 androidx.window:window:1.5.0
androidx.work:work-runtime-ktx:2.10.0 androidx.work:work-runtime-ktx:2.10.0
androidx.work:work-runtime:2.10.0 androidx.work:work-runtime:2.10.0
com.caverock:androidsvg-aar:1.4 com.caverock:androidsvg-aar:1.4
@ -212,8 +223,8 @@ com.google.protobuf:protobuf-javalite:4.29.2
com.google.protobuf:protobuf-kotlin-lite:4.29.2 com.google.protobuf:protobuf-kotlin-lite:4.29.2
com.squareup.okhttp3:logging-interceptor:4.12.0 com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio-jvm:3.9.1
com.squareup.okio:okio:3.9.0 com.squareup.okio:okio:3.9.1
com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0 com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0
com.squareup.retrofit2:retrofit:2.11.0 com.squareup.retrofit2:retrofit:2.11.0
io.coil-kt:coil-base:2.7.0 io.coil-kt:coil-base:2.7.0
@ -224,8 +235,6 @@ io.coil-kt:coil:2.7.0
jakarta.inject:jakarta.inject-api:2.0.1 jakarta.inject:jakarta.inject-api:2.0.1
javax.inject:javax.inject:1 javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0 org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.22
org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21 org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0

@ -1,6 +1,6 @@
package: name='com.google.samples.apps.nowinandroid' versionCode='8' versionName='0.1.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' package: name='com.google.samples.apps.nowinandroid' versionCode='8' versionName='0.1.2' platformBuildVersionName='16' platformBuildVersionCode='36' compileSdkVersion='36' compileSdkVersionCodename='16'
minSdkVersion:'23' minSdkVersion:'23'
targetSdkVersion:'35' targetSdkVersion:'36'
uses-permission: name='android.permission.INTERNET' uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.ACCESS_NETWORK_STATE' uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
uses-permission: name='android.permission.POST_NOTIFICATIONS' uses-permission: name='android.permission.POST_NOTIFICATIONS'
@ -105,9 +105,9 @@ application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml' application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
application: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml' application: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon='' launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon=''
uses-library-not-required:'android.ext.adservices'
uses-library-not-required:'androidx.window.extensions' uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar' uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label='' feature-group: label=''
uses-feature: name='android.hardware.faketouch' uses-feature: name='android.hardware.faketouch'
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps' uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.ui.semantics.SemanticsActions.ScrollBy import androidx.compose.ui.semantics.SemanticsActions.ScrollBy
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
@ -39,18 +40,20 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Ignore
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR import com.google.samples.apps.nowinandroid.feature.foryou.api.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR import com.google.samples.apps.nowinandroid.feature.search.api.R as FeatureSearchR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR import com.google.samples.apps.nowinandroid.feature.settings.impl.R as SettingsR
/** /**
* Tests all the navigation flows that are handled by the navigation library. * Tests all the navigation flows that are handled by the navigation library.
@ -83,15 +86,15 @@ class NavigationTest {
lateinit var newsRepository: NewsRepository lateinit var newsRepository: NewsRepository
// The strings used for matching in these tests // The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up) private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title) private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_title)
private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_interests) private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_api_interests)
private val sampleTopic = "Headlines" private val sampleTopic = "Headlines"
private val appName by composeTestRule.stringResource(R.string.app_name) private val appName by composeTestRule.stringResource(R.string.app_name)
private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_title) private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_api_title)
private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_top_app_bar_action_icon_description) private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_top_app_bar_action_icon_description)
private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android) private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text) private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_dismiss_dialog_button_text)
@Before @Before
fun setup() = hiltRule.inject() fun setup() = hiltRule.inject()
@ -252,6 +255,9 @@ class NavigationTest {
} }
} }
// TODO decide if backStack should preserve previous stacks when navigating back to home tab (ForYou)
// https://github.com/android/nowinandroid/issues/1937
@Ignore
@Test @Test
fun navigationBar_multipleBackStackInterests() { fun navigationBar_multipleBackStackInterests() {
composeTestRule.apply { composeTestRule.apply {
@ -261,12 +267,14 @@ class NavigationTest {
val topic = runBlocking { val topic = runBlocking {
topicsRepository.getTopics().first().sortedBy(Topic::name).last() topicsRepository.getTopics().first().sortedBy(Topic::name).last()
} }
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name)) onNodeWithTag(LIST_PANE_TEST_TAG).performScrollToNode(hasText(topic.name))
onNodeWithText(topic.name).performClick() onNodeWithText(topic.name).performClick()
// Verify the topic is still shown
onNodeWithTag("topic:${topic.id}").assertIsDisplayed()
// Switch tab // Switch tab
onNodeWithText(forYou).performClick() onNodeWithText(forYou).performClick()
// Come back to Interests // Come back to Interests
onNodeWithText(interests).performClick() onNodeWithText(interests).performClick()

@ -1,72 +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.navigation
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.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection
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
* https://d.android.com/jetpack/compose/nav-adaptive
*
* The navigation graph defined in this file defines the different top level routes. Navigation
* within each route is handled using state and Back Handlers.
*/
@Composable
fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = ForYouBaseRoute,
modifier = modifier,
) {
forYouSection(
onTopicClick = navController::navigateToTopic,
) {
topicScreen(
showBackButton = true,
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
}
bookmarksScreen(
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}

@ -1,76 +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.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
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.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlin.reflect.KClass
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.search.R as searchR
/**
* Type for the top level destinations in the application. Contains metadata about the destination
* that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
* @param route The route to use when navigating to this destination.
* @param baseRoute The highest ancestor of this destination. Defaults to [route], meaning that
* there is a single destination in that section of the app (no nested destinations).
*/
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
val baseRoute: KClass<*> = route,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
route = ForYouRoute::class,
baseRoute = ForYouBaseRoute::class,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title,
route = BookmarksRoute::class,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests,
route = InterestsRoute::class,
),
}

@ -0,0 +1,73 @@
/*
* Copyright 2025 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.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
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.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR
/**
* Type for the top level navigation items in the application. Contains UI information about the
* current route that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
*/
data class TopLevelNavItem(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
)
val FOR_YOU = TopLevelNavItem(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name,
)
val BOOKMARKS = TopLevelNavItem(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
)
val INTERESTS = TopLevelNavItem(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
)
val TOP_LEVEL_NAV_ITEMS = mapOf(
ForYouNavKey to FOR_YOU,
BookmarksNavKey to BOOKMARKS,
InterestsNavKey(null) to INTERESTS,
)

@ -33,15 +33,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -60,9 +61,9 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation3.runtime.NavKey
import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation3.runtime.entryProvider
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation3.ui.NavDisplay
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
@ -71,11 +72,19 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.core.navigation.toEntries
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import kotlin.reflect.KClass import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.bookmarksEntry
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation.forYouEntry
import com.google.samples.apps.nowinandroid.feature.interests.impl.navigation.interestsEntry
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchNavKey
import com.google.samples.apps.nowinandroid.feature.search.impl.navigation.searchEntry
import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsDialog
import com.google.samples.apps.nowinandroid.feature.topic.impl.navigation.topicEntry
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS
import com.google.samples.apps.nowinandroid.feature.settings.impl.R as settingsR
@Composable @Composable
fun NiaApp( fun NiaApp(
@ -83,8 +92,7 @@ fun NiaApp(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground = appState.navigationState.currentTopLevelKey == ForYouNavKey
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
var showSettingsDialog by rememberSaveable { mutableStateOf(false) } var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
NiaBackground(modifier = modifier) { NiaBackground(modifier = modifier) {
@ -109,15 +117,17 @@ fun NiaApp(
) )
} }
} }
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
NiaApp(
appState = appState,
NiaApp( // TODO: Settings should be a dialog screen
appState = appState, showSettingsDialog = showSettingsDialog,
snackbarHostState = snackbarHostState, onSettingsDismissed = { showSettingsDialog = false },
showSettingsDialog = showSettingsDialog, onTopAppBarActionClick = { showSettingsDialog = true },
onSettingsDismissed = { showSettingsDialog = false }, windowAdaptiveInfo = windowAdaptiveInfo,
onTopAppBarActionClick = { showSettingsDialog = true }, )
windowAdaptiveInfo = windowAdaptiveInfo, }
)
} }
} }
} }
@ -126,19 +136,18 @@ fun NiaApp(
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class,
ExperimentalMaterial3AdaptiveApi::class,
) )
internal fun NiaApp( internal fun NiaApp(
appState: NiaAppState, appState: NiaAppState,
snackbarHostState: SnackbarHostState,
showSettingsDialog: Boolean, showSettingsDialog: Boolean,
onSettingsDismissed: () -> Unit, onSettingsDismissed: () -> Unit,
onTopAppBarActionClick: () -> Unit, onTopAppBarActionClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) { ) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources val unreadNavKeys by appState.topLevelNavKeysWithUnreadResources
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
val currentDestination = appState.currentDestination
if (showSettingsDialog) { if (showSettingsDialog) {
SettingsDialog( SettingsDialog(
@ -146,28 +155,31 @@ internal fun NiaApp(
) )
} }
val snackbarHostState = LocalSnackbarHostState.current
val navigator = remember { Navigator(appState.navigationState) }
NiaNavigationSuiteScaffold( NiaNavigationSuiteScaffold(
navigationSuiteItems = { navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination -> TOP_LEVEL_NAV_ITEMS.forEach { (navKey, navItem) ->
val hasUnread = unreadDestinations.contains(destination) val hasUnread = unreadNavKeys.contains(navKey)
val selected = currentDestination val selected = navKey == appState.navigationState.currentTopLevelKey
.isRouteInHierarchy(destination.baseRoute)
item( item(
selected = selected, selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) }, onClick = { navigator.navigate(navKey) },
icon = { icon = {
Icon( Icon(
imageVector = destination.unselectedIcon, imageVector = navItem.unselectedIcon,
contentDescription = null, contentDescription = null,
) )
}, },
selectedIcon = { selectedIcon = {
Icon( Icon(
imageVector = destination.selectedIcon, imageVector = navItem.selectedIcon,
contentDescription = null, contentDescription = null,
) )
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(navItem.iconTextId)) },
modifier = Modifier modifier = Modifier
.testTag("NiaNavItem") .testTag("NiaNavItem")
.then(if (hasUnread) Modifier.notificationDot() else Modifier), .then(if (hasUnread) Modifier.notificationDot() else Modifier),
@ -205,27 +217,30 @@ internal fun NiaApp(
), ),
), ),
) { ) {
// Show the top app bar on top level destinations. // Only show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
var shouldShowTopAppBar = false var shouldShowTopAppBar = false
if (destination != null) { if (appState.navigationState.currentKey in appState.navigationState.topLevelKeys) {
shouldShowTopAppBar = true shouldShowTopAppBar = true
val destination = TOP_LEVEL_NAV_ITEMS[appState.navigationState.currentTopLevelKey]
?: error("Top level nav item not found for ${appState.navigationState.currentTopLevelKey}")
NiaTopAppBar( NiaTopAppBar(
titleRes = destination.titleTextId, titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search, navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource( navigationIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_navigation_icon_description, id = settingsR.string.feature_settings_impl_top_app_bar_navigation_icon_description,
), ),
actionIcon = NiaIcons.Settings, actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource( actionIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_action_icon_description, id = settingsR.string.feature_settings_impl_top_app_bar_action_icon_description,
), ),
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
onActionClick = { onTopAppBarActionClick() }, onActionClick = { onTopAppBarActionClick() },
onNavigationClick = { appState.navigateToSearch() }, onNavigationClick = { navigator.navigate(SearchNavKey) },
) )
} }
@ -239,15 +254,20 @@ internal fun NiaApp(
}, },
), ),
) { ) {
NiaNavHost( val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
appState = appState,
onShowSnackbar = { message, action -> val entryProvider = entryProvider {
snackbarHostState.showSnackbar( forYouEntry(navigator)
message = message, bookmarksEntry(navigator)
actionLabel = action, interestsEntry(navigator)
duration = Short, topicEntry(navigator)
) == ActionPerformed searchEntry(navigator)
}, }
NavDisplay(
entries = appState.navigationState.toEntries(entryProvider),
sceneStrategy = listDetailStrategy,
onBack = { navigator.goBack() },
) )
} }
@ -276,8 +296,3 @@ private fun Modifier.notificationDot(): Modifier =
) )
} }
} }
private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
this?.hierarchy?.any {
it.hasRoute(route)
} ?: false

@ -18,30 +18,18 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController import androidx.navigation3.runtime.NavKey
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
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.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor 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.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.navigation.NavigationState
import com.google.samples.apps.nowinandroid.core.navigation.rememberNavigationState
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS
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
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -56,18 +44,20 @@ fun rememberNiaAppState(
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor, timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(), coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState { ): NiaAppState {
NavigationTrackingSideEffect(navController) val navigationState = rememberNavigationState(ForYouNavKey, TOP_LEVEL_NAV_ITEMS.keys)
NavigationTrackingSideEffect(navigationState)
return remember( return remember(
navController, navigationState,
coroutineScope, coroutineScope,
networkMonitor, networkMonitor,
userNewsResourceRepository, userNewsResourceRepository,
timeZoneMonitor, timeZoneMonitor,
) { ) {
NiaAppState( NiaAppState(
navController = navController, navigationState = navigationState,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
@ -78,35 +68,12 @@ fun rememberNiaAppState(
@Stable @Stable
class NiaAppState( class NiaAppState(
val navController: NavHostController, val navigationState: NavigationState,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor, timeZoneMonitor: TimeZoneMonitor,
) { ) {
private val previousDestination = mutableStateOf<NavDestination?>(null)
val currentDestination: NavDestination?
@Composable get() {
// Collect the currentBackStackEntryFlow as a state
val currentEntry = navController.currentBackStackEntryFlow
.collectAsState(initial = null)
// Fallback to previousDestination if currentEntry is null
return currentEntry.value?.destination.also { destination ->
if (destination != null) {
previousDestination.value = destination
}
} ?: previousDestination.value
}
val currentTopLevelDestination: TopLevelDestination?
@Composable get() {
return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
currentDestination?.hasRoute(route = topLevelDestination.route) == true
}
}
val isOffline = networkMonitor.isOnline val isOffline = networkMonitor.isOnline
.map(Boolean::not) .map(Boolean::not)
.stateIn( .stateIn(
@ -116,20 +83,14 @@ class NiaAppState(
) )
/** /**
* Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the * The top level nav keys that have unread news resources.
* route.
*/
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
/**
* The top level destinations that have unread news resources.
*/ */
val topLevelDestinationsWithUnreadResources: StateFlow<Set<TopLevelDestination>> = val topLevelNavKeysWithUnreadResources: StateFlow<Set<NavKey>> =
userNewsResourceRepository.observeAllForFollowedTopics() userNewsResourceRepository.observeAllForFollowedTopics()
.combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources -> .combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->
setOfNotNull( setOfNotNull(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } }, ForYouNavKey.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } }, BookmarksNavKey.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
) )
} }
.stateIn( .stateIn(
@ -144,55 +105,15 @@ class NiaAppState(
SharingStarted.WhileSubscribed(5_000), SharingStarted.WhileSubscribed(5_000),
TimeZone.currentSystemDefault(), 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
* navigate to and from it.
*
* @param topLevelDestination: The destination the app needs to navigate to.
*/
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
trace("Navigation: ${topLevelDestination.name}") {
val topLevelNavOptions = navOptions {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions)
}
}
}
fun navigateToSearch() = navController.navigateToSearch()
} }
/** /**
* Stores information about navigation events to be used with JankStats * Stores information about navigation events to be used with JankStats
*/ */
@Composable @Composable
private fun NavigationTrackingSideEffect(navController: NavHostController) { private fun NavigationTrackingSideEffect(navigationState: NavigationState) {
TrackDisposableJank(navController) { metricsHolder -> TrackDisposableJank(navigationState.currentKey) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ -> metricsHolder.state?.putState("Navigation", navigationState.currentKey.toString())
metricsHolder.state?.putState("Navigation", destination.route.toString()) onDispose {}
}
navController.addOnDestinationChangedListener(listener)
onDispose {
navController.removeOnDestinationChangedListener(listener)
}
} }
} }

@ -1,43 +0,0 @@
/*
* 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 androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
const val TOPIC_ID_KEY = "selectedTopicId"
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val route = savedStateHandle.toRoute<InterestsRoute>()
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
key = TOPIC_ID_KEY,
initialValue = route.initialTopicId,
)
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_KEY] = topicId
}
}

@ -1,244 +0,0 @@
/*
* 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.animation.AnimatedContent
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.VerticalDragHandle
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.TopicViewModel
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.math.max
@Serializable internal object TopicPlaceholderRoute
fun NavGraphBuilder.interestsListDetailScreen() {
composable<InterestsRoute> {
InterestsListDetailScreen()
}
}
@Composable
internal fun InterestsListDetailScreen(
viewModel: Interests2PaneViewModel = hiltViewModel(),
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
InterestsListDetailScreen(
selectedTopicId = selectedTopicId,
onTopicClick = viewModel::onTopicClick,
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
internal fun InterestsListDetailScreen(
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
windowAdaptiveInfo: WindowAdaptiveInfo,
) {
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo),
initialDestinationHistory = listOfNotNull(
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
selectedTopicId != null
},
),
)
val coroutineScope = rememberCoroutineScope()
val paneExpansionState = rememberPaneExpansionState(
anchors = listOf(
PaneExpansionAnchor.Proportion(0f),
PaneExpansionAnchor.Proportion(0.5f),
PaneExpansionAnchor.Proportion(1f),
),
)
ThreePaneScaffoldPredictiveBackHandler(
listDetailNavigator,
BackNavigationBehavior.PopUntilScaffoldValueChange,
)
BackHandler(
paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(0f) &&
listDetailNavigator.isListPaneVisible() &&
listDetailNavigator.isDetailPaneVisible(),
) {
coroutineScope.launch {
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(1f))
}
}
var topicRoute by remember {
val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
}
fun onTopicClickShowDetailPane(topicId: String) {
onTopicClick(topicId)
topicRoute = TopicRoute(id = topicId)
coroutineScope.launch {
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
if (paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(1f)) {
coroutineScope.launch {
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(0f))
}
}
}
val mutableInteractionSource = remember { MutableInteractionSource() }
val minPaneWidth = 300.dp
NavigableListDetailPaneScaffold(
navigator = listDetailNavigator,
listPane = {
AnimatedPane {
Box(
modifier = Modifier.clipToBounds()
.layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
val placeable = measurable.measure(
constraints.copy(
minWidth = minPaneWidth.roundToPx(),
maxWidth = width,
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.placeRelative(
x = 0,
y = 0,
)
}
},
) {
InterestsRoute(
onTopicClick = ::onTopicClickShowDetailPane,
shouldHighlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
)
}
}
},
detailPane = {
AnimatedPane {
Box(
modifier = Modifier.clipToBounds()
.layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
val placeable = measurable.measure(
constraints.copy(
minWidth = minPaneWidth.roundToPx(),
maxWidth = width,
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.placeRelative(
x = constraints.maxWidth -
max(constraints.maxWidth, placeable.width),
y = 0,
)
}
},
) {
AnimatedContent(topicRoute) { route ->
when (route) {
is TopicRoute -> {
TopicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = {
coroutineScope.launch {
listDetailNavigator.navigateBack()
}
},
onTopicClick = ::onTopicClickShowDetailPane,
viewModel = hiltViewModel<TopicViewModel, TopicViewModel.Factory>(
key = route.id,
) { factory ->
factory.create(route.id)
},
)
}
is TopicPlaceholderRoute -> {
TopicDetailPlaceholder()
}
}
}
}
}
},
paneExpansionState = paneExpansionState,
paneExpansionDragHandle = {
VerticalDragHandle(
modifier = Modifier.paneExpansionDraggable(
state = paneExpansionState,
minTouchTargetSize = LocalMinimumInteractiveComponentSize.current,
interactionSource = mutableInteractionSource,
semanticsProperties = paneExpansionState.defaultDragHandleSemantics(),
),
interactionSource = mutableInteractionSource,
)
},
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded

@ -16,21 +16,19 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation.NavHostController import androidx.navigation3.runtime.NavBackStack
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable
import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.navigation.NavigationState
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository 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.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -44,7 +42,6 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** /**
* Tests [NiaAppState]. * Tests [NiaAppState].
@ -68,32 +65,42 @@ class NiaAppStateTest {
// Subject under test. // Subject under test.
private lateinit var state: NiaAppState private lateinit var state: NiaAppState
private fun testNavigationState() = NavigationState(
startKey = ForYouNavKey,
topLevelStack = NavBackStack(ForYouNavKey),
subStacks = mapOf(
ForYouNavKey to NavBackStack(ForYouNavKey),
BookmarksNavKey to NavBackStack(BookmarksNavKey),
),
)
@Test @Test
fun niaAppState_currentDestination() = runTest { fun niaAppState_currentDestination() = runTest {
var currentDestination: String? = null val navigationState = testNavigationState()
val navigator = Navigator(navigationState)
composeTestRule.setContent { composeTestRule.setContent {
val navController = rememberTestNavController() state = remember(navigationState) {
state = remember(navController) {
NiaAppState( NiaAppState(
navController = navController,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
navigationState = navigationState,
) )
} }
}
// Update currentDestination whenever it changes assertEquals(ForYouNavKey, state.navigationState.currentTopLevelKey)
currentDestination = state.currentDestination?.route assertEquals(ForYouNavKey, state.navigationState.currentKey)
// Navigate to destination b once // Navigate to another destination once
LaunchedEffect(Unit) { navigator.navigate(BookmarksNavKey)
navController.setCurrentDestination("b")
}
}
assertEquals("b", currentDestination) composeTestRule.waitForIdle()
assertEquals(BookmarksNavKey, state.navigationState.currentTopLevelKey)
assertEquals(BookmarksNavKey, state.navigationState.currentKey)
} }
@Test @Test
@ -106,21 +113,24 @@ class NiaAppStateTest {
) )
} }
assertEquals(3, state.topLevelDestinations.size) val navigationState = state.navigationState
assertTrue(state.topLevelDestinations[0].name.contains("for_you", true))
assertTrue(state.topLevelDestinations[1].name.contains("bookmarks", true)) assertEquals(3, navigationState.topLevelKeys.size)
assertTrue(state.topLevelDestinations[2].name.contains("interests", true)) assertEquals(
setOf(ForYouNavKey, BookmarksNavKey, InterestsNavKey(null)),
navigationState.topLevelKeys,
)
} }
@Test @Test
fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) { fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
navigationState = testNavigationState(),
) )
} }
@ -136,11 +146,11 @@ class NiaAppStateTest {
fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) { fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
navigationState = testNavigationState(),
) )
} }
val changedTz = TimeZone.of("Europe/Prague") val changedTz = TimeZone.of("Europe/Prague")
@ -152,18 +162,3 @@ class NiaAppStateTest {
) )
} }
} }
@Composable
private fun rememberTestNavController(): TestNavHostController {
val context = LocalContext.current
return remember {
TestNavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator())
graph = createGraph(startDestination = "a") {
composable("a") { }
composable("b") { }
composable("c") { }
}
}
}
}

@ -67,6 +67,7 @@ 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.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -147,9 +148,7 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun phone_noSnackbar() { fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"insets_snackbar_compact_medium_noSnackbar", "insets_snackbar_compact_medium_noSnackbar",
@ -159,13 +158,11 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun snackbarShown_phone() { fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"insets_snackbar_compact_medium", "insets_snackbar_compact_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -176,13 +173,11 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun snackbarShown_foldable() { fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp, 600.dp,
600.dp, 600.dp,
"insets_snackbar_medium_medium", "insets_snackbar_medium_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -193,13 +188,11 @@ class SnackbarInsetsScreenshotTests {
@Test @Test
fun snackbarShown_tablet() { fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp, 900.dp,
900.dp, 900.dp,
"insets_snackbar_expanded_expanded", "insets_snackbar_expanded_expanded",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -209,17 +202,18 @@ class SnackbarInsetsScreenshotTests {
} }
private fun testSnackbarScreenshotWithSize( private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp, width: Dp,
height: Dp, height: Dp,
screenshotName: String, screenshotName: String,
action: suspend () -> Unit, action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) { ) {
lateinit var scope: CoroutineScope lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider( CompositionLocalProvider(
// Replaces images with placeholders // Replaces images with placeholders
LocalInspectionMode provides true, LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) { ) {
scope = rememberCoroutineScope() scope = rememberCoroutineScope()
@ -259,7 +253,6 @@ class SnackbarInsetsScreenshotTests {
) )
NiaApp( NiaApp(
appState = appState, appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false, showSettingsDialog = false,
onSettingsDismissed = {}, onSettingsDismissed = {},
onTopAppBarActionClick = {}, onTopAppBarActionClick = {},
@ -280,7 +273,7 @@ class SnackbarInsetsScreenshotTests {
} }
scope.launch { scope.launch {
action() action(snackbarHostState)
} }
composeTestRule.onNodeWithTag("root") composeTestRule.onNodeWithTag("root")

@ -40,6 +40,7 @@ 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.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -120,9 +121,7 @@ class SnackbarScreenshotTests {
@Test @Test
fun phone_noSnackbar() { fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"snackbar_compact_medium_noSnackbar", "snackbar_compact_medium_noSnackbar",
@ -132,13 +131,11 @@ class SnackbarScreenshotTests {
@Test @Test
fun snackbarShown_phone() { fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp, 400.dp,
500.dp, 500.dp,
"snackbar_compact_medium", "snackbar_compact_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -149,13 +146,11 @@ class SnackbarScreenshotTests {
@Test @Test
fun snackbarShown_foldable() { fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp, 600.dp,
600.dp, 600.dp,
"snackbar_medium_medium", "snackbar_medium_medium",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -166,13 +161,11 @@ class SnackbarScreenshotTests {
@Test @Test
fun snackbarShown_tablet() { fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize( testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp, 900.dp,
900.dp, 900.dp,
"snackbar_expanded_expanded", "snackbar_expanded_expanded",
) { ) { snackbarHostState ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
"This is a test snackbar message", "This is a test snackbar message",
actionLabel = "Action Label", actionLabel = "Action Label",
@ -182,17 +175,19 @@ class SnackbarScreenshotTests {
} }
private fun testSnackbarScreenshotWithSize( private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp, width: Dp,
height: Dp, height: Dp,
screenshotName: String, screenshotName: String,
action: suspend () -> Unit, action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) { ) {
lateinit var scope: CoroutineScope lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider( CompositionLocalProvider(
// Replaces images with placeholders // Replaces images with placeholders
LocalInspectionMode provides true, LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) { ) {
scope = rememberCoroutineScope() scope = rememberCoroutineScope()
@ -208,7 +203,6 @@ class SnackbarScreenshotTests {
) )
NiaApp( NiaApp(
appState = appState, appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false, showSettingsDialog = false,
onSettingsDismissed = {}, onSettingsDismissed = {},
onTopAppBarActionClick = {}, onTopAppBarActionClick = {},
@ -227,7 +221,7 @@ class SnackbarScreenshotTests {
} }
scope.launch { scope.launch {
action() action(snackbarHostState)
} }
composeTestRule.onRoot() composeTestRule.onRoot()

@ -0,0 +1,17 @@
#
# Copyright 2025 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.
#
sdk = 35

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

@ -11,6 +11,42 @@ config:
nodePlacementStrategy: SIMPLE nodePlacementStrategy: SIMPLE
--- ---
graph TB graph TB
subgraph :feature
direction TB
subgraph :feature:settings
direction TB
:feature:settings:impl[impl]:::android-library
end
subgraph :feature:foryou
direction TB
:feature:foryou:api[api]:::android-library
:feature:foryou:impl[impl]:::android-library
end
subgraph :feature:bookmarks
direction TB
:feature:bookmarks:api[api]:::android-library
:feature:bookmarks:impl[impl]:::android-library
end
subgraph :feature:search
direction TB
:feature:search:api[api]:::android-library
:feature:search:impl[impl]:::android-library
end
subgraph :feature:interests
direction TB
:feature:interests:api[api]:::android-library
:feature:interests:impl[impl]:::android-library
end
subgraph :feature:topic
direction TB
:feature:topic:api[api]:::android-library
:feature:topic:impl[impl]:::android-library
end
end
subgraph :sync
direction TB
:sync:work[work]:::android-library
end
subgraph :core subgraph :core
direction TB direction TB
:core:analytics[analytics]:::android-library :core:analytics[analytics]:::android-library
@ -22,23 +58,11 @@ graph TB
:core:designsystem[designsystem]:::android-library :core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library :core:domain[domain]:::android-library
:core:model[model]:::jvm-library :core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library :core:network[network]:::android-library
:core:notifications[notifications]:::android-library :core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library :core:ui[ui]:::android-library
end end
subgraph :feature
direction TB
:feature:bookmarks[bookmarks]:::android-feature
:feature:foryou[foryou]:::android-feature
:feature:interests[interests]:::android-feature
:feature:search[search]:::android-feature
:feature:settings[settings]:::android-feature
:feature:topic[topic]:::android-feature
end
subgraph :sync
direction TB
:sync:work[work]:::android-library
end
:benchmarks[benchmarks]:::android-test :benchmarks[benchmarks]:::android-test
:app[app]:::android-application :app[app]:::android-application
@ -49,12 +73,17 @@ graph TB
:app -.-> :core:designsystem :app -.-> :core:designsystem
:app -.-> :core:model :app -.-> :core:model
:app -.-> :core:ui :app -.-> :core:ui
:app -.-> :feature:bookmarks :app -.-> :feature:bookmarks:api
:app -.-> :feature:foryou :app -.-> :feature:bookmarks:impl
:app -.-> :feature:interests :app -.-> :feature:foryou:api
:app -.-> :feature:search :app -.-> :feature:foryou:impl
:app -.-> :feature:settings :app -.-> :feature:interests:api
:app -.-> :feature:topic :app -.-> :feature:interests:impl
:app -.-> :feature:search:api
:app -.-> :feature:search:impl
:app -.-> :feature:settings:impl
:app -.-> :feature:topic:api
:app -.-> :feature:topic:impl
:app -.-> :sync:work :app -.-> :sync:work
:benchmarks -.->|testedApks| :app :benchmarks -.->|testedApks| :app
:core:data -.-> :core:analytics :core:data -.-> :core:analytics
@ -76,28 +105,43 @@ graph TB
:core:ui --> :core:analytics :core:ui --> :core:analytics
:core:ui --> :core:designsystem :core:ui --> :core:designsystem
:core:ui --> :core:model :core:ui --> :core:model
:feature:bookmarks -.-> :core:data :feature:bookmarks:api --> :core:navigation
:feature:bookmarks -.-> :core:designsystem :feature:bookmarks:impl -.-> :core:data
:feature:bookmarks -.-> :core:ui :feature:bookmarks:impl -.-> :core:designsystem
:feature:foryou -.-> :core:data :feature:bookmarks:impl -.-> :core:ui
:feature:foryou -.-> :core:designsystem :feature:bookmarks:impl -.-> :feature:bookmarks:api
:feature:foryou -.-> :core:domain :feature:bookmarks:impl -.-> :feature:topic:api
:feature:foryou -.-> :core:notifications :feature:foryou:api --> :core:navigation
:feature:foryou -.-> :core:ui :feature:foryou:impl -.-> :core:designsystem
:feature:interests -.-> :core:data :feature:foryou:impl -.-> :core:domain
:feature:interests -.-> :core:designsystem :feature:foryou:impl -.-> :core:notifications
:feature:interests -.-> :core:domain :feature:foryou:impl -.-> :core:ui
:feature:interests -.-> :core:ui :feature:foryou:impl -.-> :feature:foryou:api
:feature:search -.-> :core:data :feature:foryou:impl -.-> :feature:topic:api
:feature:search -.-> :core:designsystem :feature:interests:api --> :core:navigation
:feature:search -.-> :core:domain :feature:interests:impl -.-> :core:designsystem
:feature:search -.-> :core:ui :feature:interests:impl -.-> :core:domain
:feature:settings -.-> :core:data :feature:interests:impl -.-> :core:ui
:feature:settings -.-> :core:designsystem :feature:interests:impl -.-> :feature:interests:api
:feature:settings -.-> :core:ui :feature:interests:impl -.-> :feature:topic:api
:feature:topic -.-> :core:data :feature:search:api -.-> :core:domain
:feature:topic -.-> :core:designsystem :feature:search:api --> :core:navigation
:feature:topic -.-> :core:ui :feature:search:impl -.-> :core:designsystem
:feature:search:impl -.-> :core:domain
:feature:search:impl -.-> :core:ui
:feature:search:impl -.-> :feature:interests:api
:feature:search:impl -.-> :feature:search:api
:feature:search:impl -.-> :feature:topic:api
:feature:settings:impl -.-> :core:data
:feature:settings:impl -.-> :core:designsystem
:feature:settings:impl -.-> :core:ui
:feature:topic:api -.-> :core:designsystem
:feature:topic:api --> :core:navigation
:feature:topic:api -.-> :core:ui
:feature:topic:impl -.-> :core:data
:feature:topic:impl -.-> :core:designsystem
:feature:topic:impl -.-> :core:ui
:feature:topic:impl -.-> :feature:topic:api
:sync:work -.-> :core:analytics :sync:work -.-> :core:analytics
:sync:work -.-> :core:data :sync:work -.-> :core:data
:sync:work -.-> :core:notifications :sync:work -.-> :core:notifications

@ -78,9 +78,13 @@ gradlePlugin {
id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
implementationClass = "AndroidLibraryConventionPlugin" implementationClass = "AndroidLibraryConventionPlugin"
} }
register("androidFeature") { register("androidFeatureImpl") {
id = libs.plugins.nowinandroid.android.feature.get().pluginId id = libs.plugins.nowinandroid.android.feature.impl.get().pluginId
implementationClass = "AndroidFeatureConventionPlugin" implementationClass = "AndroidFeatureImplConventionPlugin"
}
register("androidFeatureApi") {
id = libs.plugins.nowinandroid.android.feature.api.get().pluginId
implementationClass = "AndroidFeatureApiConventionPlugin"
} }
register("androidLibraryJacoco") { register("androidLibraryJacoco") {
id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId

@ -31,5 +31,4 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
configureAndroidCompose(extension) configureAndroidCompose(extension)
} }
} }
} }

@ -36,7 +36,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 35 defaultConfig.targetSdk = 36
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
testOptions.animationsDisabled = true testOptions.animationsDisabled = true
configureGradleManagedDevices(this) configureGradleManagedDevices(this)

@ -0,0 +1,34 @@
/*
* 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.
*/
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureApiConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
dependencies {
"api"(project(":core:navigation"))
}
}
}
}

@ -23,12 +23,11 @@ import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureConventionPlugin : Plugin<Project> { class AndroidFeatureImplConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
apply(plugin = "nowinandroid.android.library") apply(plugin = "nowinandroid.android.library")
apply(plugin = "nowinandroid.hilt") apply(plugin = "nowinandroid.hilt")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true testOptions.animationsDisabled = true
@ -39,14 +38,12 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
"implementation"(project(":core:ui")) "implementation"(project(":core:ui"))
"implementation"(project(":core:designsystem")) "implementation"(project(":core:designsystem"))
"implementation"(libs.findLibrary("androidx.hilt.navigation.compose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) "implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get()) "implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation.compose").get()) "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation3.runtime").get())
"implementation"(libs.findLibrary("androidx.tracing.ktx").get()) "implementation"(libs.findLibrary("androidx.tracing.ktx").get())
"implementation"(libs.findLibrary("kotlinx.serialization.json").get())
"testImplementation"(libs.findLibrary("androidx.navigation.testing").get())
"androidTestImplementation"( "androidTestImplementation"(
libs.findLibrary("androidx.lifecycle.runtimeTesting").get(), libs.findLibrary("androidx.lifecycle.runtimeTesting").get(),
) )

@ -31,5 +31,4 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
configureAndroidCompose(extension) configureAndroidCompose(extension)
} }
} }
} }

@ -37,8 +37,9 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
testOptions.targetSdk = 35 testOptions.targetSdk = 36
lint.targetSdk = 35 lint.targetSdk = 36
defaultConfig.targetSdk = 36
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testOptions.animationsDisabled = true testOptions.animationsDisabled = true
configureFlavors(this) configureFlavors(this)

@ -30,7 +30,7 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> { extensions.configure<TestExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 35 defaultConfig.targetSdk = 36
configureGradleManagedDevices(this) configureGradleManagedDevices(this)
} }
} }

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.provider.Provider import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension

@ -187,20 +187,46 @@ private abstract class GraphDumpTask : DefaultTask() {
) )
// Graph declaration // Graph declaration
appendLine("graph TB") appendLine("graph TB")
// Nodes and subgraphs (limited to a single nested layer) // Nodes and subgraphs
val (rootProjects, nestedProjects) = dependencies val (rootProjects, nestedProjects) = dependencies
.map { listOf(it.project, it.dependency) }.flatten().toSet() .map { listOf(it.project, it.dependency) }.flatten().toSet()
.plus(projectPath.get()) // Special case when this specific module has no other dependency .plus(projectPath.get()) // Special case when this specific module has no other dependency
.groupBy { it.substringBeforeLast(":") } .groupBy { it.substringBeforeLast(":") }
.entries.partition { it.key.isEmpty() } .entries.partition { it.key.isEmpty() }
nestedProjects.sortedByDescending { it.value.size }.forEach { (group, projects) ->
appendLine(" subgraph $group") val orderedGroups = nestedProjects.groupBy {
appendLine(" direction TB") if (it.key.count { char -> char == ':' } > 1) it.key.substringBeforeLast(":") else ""
projects.sorted().forEach { }
appendLine(it.alias(indent = 4, plugins.get().getValue(it)))
orderedGroups.forEach { (outerGroup, innerGroups) ->
if (outerGroup.isNotEmpty()) {
appendLine(" subgraph $outerGroup")
appendLine(" direction TB")
}
innerGroups.sortedWith(
compareBy(
{ (group, _) ->
dependencies.filter { dep ->
val toGroup = dep.dependency.substringBeforeLast(":")
toGroup == group && dep.project.substringBeforeLast(":") != group
}.count()
},
{ -it.value.size },
),
).forEach { (group, projects) ->
val indent = if (outerGroup.isNotEmpty()) 4 else 2
appendLine(" ".repeat(indent) + "subgraph $group")
appendLine(" ".repeat(indent) + " direction TB")
projects.sorted().forEach {
appendLine(it.alias(indent = indent + 2, plugins.get().getValue(it)))
}
appendLine(" ".repeat(indent) + "end")
}
if (outerGroup.isNotEmpty()) {
appendLine(" end")
} }
appendLine(" end")
} }
rootProjects.flatMap { it.value }.sortedDescending().forEach { rootProjects.flatMap { it.value }.sortedDescending().forEach {
appendLine(it.alias(indent = 2, plugins.get().getValue(it))) appendLine(it.alias(indent = 2, plugins.get().getValue(it)))
} }

@ -35,7 +35,7 @@ internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>, commonExtension: CommonExtension<*, *, *, *, *, *>,
) { ) {
commonExtension.apply { commonExtension.apply {
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
minSdk = 23 minSdk = 23

@ -0,0 +1,17 @@
#
# Copyright 2025 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.
#
sdk = 35

@ -0,0 +1,3 @@
# :core:navigation module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_navigation.svg)

@ -0,0 +1,41 @@
/*
* Copyright 2025 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.hilt)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.navigation"
}
dependencies {
api(libs.androidx.navigation3.runtime)
implementation(libs.androidx.savedstate.compose)
implementation(libs.androidx.lifecycle.viewModel.navigation3)
testImplementation(libs.truth)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.compose.ui.testManifest)
androidTestImplementation(libs.androidx.lifecycle.viewModel.testing)
androidTestImplementation(libs.truth)
}

@ -0,0 +1,102 @@
/*
* Copyright 2025 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.navigation
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
/**
* Create a navigation state that persists config changes and process death.
*/
@Composable
fun rememberNavigationState(
startKey: NavKey,
topLevelKeys: Set<NavKey>,
): NavigationState {
val topLevelStack = rememberNavBackStack(startKey)
val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) }
return remember(startKey, topLevelKeys) {
NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks,
)
}
}
/**
* State holder for navigation state.
*
* @param startKey - the starting navigation key. The user will exit the app through this key.
* @param topLevelStack - the top level back stack. It holds only top level keys.
* @param subStacks - the back stacks for each top level key
*/
class NavigationState(
val startKey: NavKey,
val topLevelStack: NavBackStack<NavKey>,
val subStacks: Map<NavKey, NavBackStack<NavKey>>,
) {
val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() }
val topLevelKeys
get() = subStacks.keys
@get:VisibleForTesting
val currentSubStack: NavBackStack<NavKey>
get() = subStacks[currentTopLevelKey]
?: error("Sub stack for $currentTopLevelKey does not exist")
@get:VisibleForTesting
val currentKey: NavKey by derivedStateOf { currentSubStack.last() }
}
/**
* Convert NavigationState into NavEntries.
*/
@Composable
fun NavigationState.toEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>,
): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = subStacks.mapValues { (_, stack) ->
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
rememberViewModelStoreNavEntryDecorator<NavKey>(),
)
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = decorators,
entryProvider = entryProvider,
)
}
return topLevelStack
.flatMap { decoratedEntries[it] ?: emptyList() }
.toMutableStateList()
}

@ -0,0 +1,91 @@
/*
* Copyright 2025 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.navigation
import androidx.navigation3.runtime.NavKey
/**
* Handles navigation events (forward and back) by updating the navigation state.
*
* @param state - The navigation state that will be updated in response to navigation events.
*/
class Navigator(val state: NavigationState) {
/**
* Navigate to a navigation key
*
* @param key - the navigation key to navigate to.
*/
fun navigate(key: NavKey) {
when (key) {
state.currentTopLevelKey -> clearSubStack()
in state.topLevelKeys -> goToTopLevel(key)
else -> goToKey(key)
}
}
/**
* Go back to the previous navigation key.
*/
fun goBack() {
when (state.currentKey) {
state.startKey -> error("You cannot go back from the start route")
state.currentTopLevelKey -> {
// We're at the base of the current sub stack, go back to the previous top level
// stack.
state.topLevelStack.removeLastOrNull()
}
else -> state.currentSubStack.removeLastOrNull()
}
}
/**
* Go to a non top level key.
*/
private fun goToKey(key: NavKey) {
state.currentSubStack.apply {
// Remove it if it's already in the stack so it's added at the end.
remove(key)
add(key)
}
}
/**
* Go to a top level stack.
*/
private fun goToTopLevel(key: NavKey) {
state.topLevelStack.apply {
if (key == state.startKey) {
// This is the start key. Clear the stack so it's added as the only key.
clear()
} else {
// Remove it if it's already in the stack so it's added at the end.
remove(key)
}
add(key)
}
}
/**
* Clearing all but the root key in the current sub stack.
*/
private fun clearSubStack() {
state.currentSubStack.run {
if (size > 1) subList(1, size).clear()
}
}
}

@ -0,0 +1,256 @@
/*
* Copyright 2025 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.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
private object TestFirstTopLevelKey : NavKey
private object TestSecondTopLevelKey : NavKey
private object TestThirdTopLevelKey : NavKey
private object TestKeyFirst : NavKey
private object TestKeySecond : NavKey
class NavigatorTest {
private lateinit var navigationState: NavigationState
private lateinit var navigator: Navigator
@Before
fun setup() {
val startKey = TestFirstTopLevelKey
val topLevelStack = NavBackStack<NavKey>(startKey)
val topLevelKeys = listOf(
startKey,
TestSecondTopLevelKey,
TestThirdTopLevelKey,
)
val subStacks = topLevelKeys.associateWith { key -> NavBackStack(key) }
navigationState = NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks,
)
navigator = Navigator(navigationState)
}
@Test
fun testStartKey() {
assertThat(navigationState.startKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testNavigate() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.subStacks[TestFirstTopLevelKey]?.last()).isEqualTo(TestKeyFirst)
}
@Test
fun testNavigateTopLevel() {
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
}
@Test
fun testNavigateSingleTop() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
}
@Test
fun testNavigateTopLevelSingleTop() {
navigator.navigate(TestSecondTopLevelKey)
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
TestKeyFirst,
).inOrder()
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
).inOrder()
}
@Test
fun testSubStack() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testMultiStack() {
// add to start stack
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
// navigate to new top level
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// add to new stack
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// go back to start stack
navigator.navigate(TestFirstTopLevelKey)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testPopOneNonTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testPopOneTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// remove TopLevel
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun popMultipleNonTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
navigator.goBack()
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun popMultipleTopLevel() {
// second sub-stack
navigator.navigate(TestSecondTopLevelKey)
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
TestKeyFirst,
).inOrder()
// third sub-stack
navigator.navigate(TestThirdTopLevelKey)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestThirdTopLevelKey,
TestKeySecond,
).inOrder()
repeat(4) {
navigator.goBack()
}
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun throwOnEmptyBackStack() {
assertFailsWith<IllegalStateException> {
navigator.goBack()
}
}
}

@ -1,4 +1,4 @@
# `:feature:settings` # `:feature:bookmarks:api`
## Module dependency graph ## Module dependency graph
@ -11,45 +11,19 @@ config:
nodePlacementStrategy: SIMPLE nodePlacementStrategy: SIMPLE
--- ---
graph TB graph TB
subgraph :core subgraph :feature
direction TB direction TB
:core:analytics[analytics]:::android-library subgraph :feature:bookmarks
:core:common[common]:::jvm-library direction TB
:core:data[data]:::android-library :feature:bookmarks:api[api]:::android-library
:core:database[database]:::android-library end
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library
:core:designsystem[designsystem]:::android-library
:core:model[model]:::jvm-library
:core:network[network]:::android-library
:core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library
end end
subgraph :feature subgraph :core
direction TB direction TB
:feature:settings[settings]:::android-feature :core:navigation[navigation]:::android-library
end end
:core:data -.-> :core:analytics :feature:bookmarks:api --> :core:navigation
:core:data --> :core:common
:core:data --> :core:database
:core:data --> :core:datastore
:core:data --> :core:network
:core:data -.-> :core:notifications
:core:database --> :core:model
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
:core:network --> :core:common
:core:network --> :core:model
:core:notifications -.-> :core:common
:core:notifications --> :core:model
:core:ui --> :core:analytics
:core:ui --> :core:designsystem
:core:ui --> :core:model
:feature:settings -.-> :core:data
:feature:settings -.-> :core:designsystem
:feature:settings -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000; classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;

@ -0,0 +1,23 @@
/*
* 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.feature.api)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api"
}

@ -0,0 +1,23 @@
/*
* 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.feature.bookmarks.api.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
object BookmarksNavKey : NavKey

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M16,2H55C57.209,2 59,3.791 59,6V52"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startX="8"
android:startY="8"
android:endX="56"
android:endY="56"
android:type="linear">
<item android:offset="0" android:color="#FFFFA8FF"/>
<item android:offset="1" android:color="#FFFF8B5E"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M45,10H9C6.791,10 5,11.791 5,14V55.854C5,59.177 8.817,61.051 11.446,59.019L24.554,48.89C25.995,47.777 28.005,47.777 29.446,48.89L42.554,59.019C45.183,61.051 49,59.177 49,55.854V14C49,11.791 47.209,10 45,10Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startX="8"
android:startY="8"
android:endX="56"
android:endY="56"
android:type="linear">
<item android:offset="0" android:color="#FFFFA8FF"/>
<item android:offset="1" android:color="#FFFF8B5E"/>
</gradient>
</aapt:attr>
</path>
</vector>

@ -15,10 +15,10 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<string name="feature_bookmarks_title">Saved</string> <string name="feature_bookmarks_api_title">Saved</string>
<string name="feature_bookmarks_loading">Loading saved…</string> <string name="feature_bookmarks_api_loading">Loading saved…</string>
<string name="feature_bookmarks_empty_error">No saved updates</string> <string name="feature_bookmarks_api_empty_error">No saved updates</string>
<string name="feature_bookmarks_empty_description">Updates you save will be stored here\nto read later</string> <string name="feature_bookmarks_api_empty_description">Updates you save will be stored here\nto read later</string>
<string name="feature_bookmarks_removed">Bookmark removed</string> <string name="feature_bookmarks_api_removed">Bookmark removed</string>
<string name="feature_bookmarks_undo">UNDO</string> <string name="feature_bookmarks_api_undo">UNDO</string>
</resources> </resources>

@ -1,4 +1,4 @@
# `:feature:interests` # `:feature:bookmarks:impl`
## Module dependency graph ## Module dependency graph
@ -11,6 +11,18 @@ config:
nodePlacementStrategy: SIMPLE nodePlacementStrategy: SIMPLE
--- ---
graph TB graph TB
subgraph :feature
direction TB
subgraph :feature:bookmarks
direction TB
:feature:bookmarks:api[api]:::android-library
:feature:bookmarks:impl[impl]:::android-library
end
subgraph :feature:topic
direction TB
:feature:topic:api[api]:::android-library
end
end
subgraph :core subgraph :core
direction TB direction TB
:core:analytics[analytics]:::android-library :core:analytics[analytics]:::android-library
@ -20,16 +32,12 @@ graph TB
:core:datastore[datastore]:::android-library :core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library :core:datastore-proto[datastore-proto]:::android-library
:core:designsystem[designsystem]:::android-library :core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library
:core:model[model]:::jvm-library :core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library :core:network[network]:::android-library
:core:notifications[notifications]:::android-library :core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library :core:ui[ui]:::android-library
end end
subgraph :feature
direction TB
:feature:interests[interests]:::android-feature
end
:core:data -.-> :core:analytics :core:data -.-> :core:analytics
:core:data --> :core:common :core:data --> :core:common
@ -41,8 +49,6 @@ graph TB
:core:datastore -.-> :core:common :core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto :core:datastore --> :core:datastore-proto
:core:datastore --> :core:model :core:datastore --> :core:model
:core:domain --> :core:data
:core:domain --> :core:model
:core:network --> :core:common :core:network --> :core:common
:core:network --> :core:model :core:network --> :core:model
:core:notifications -.-> :core:common :core:notifications -.-> :core:common
@ -50,10 +56,15 @@ graph TB
:core:ui --> :core:analytics :core:ui --> :core:analytics
:core:ui --> :core:designsystem :core:ui --> :core:designsystem
:core:ui --> :core:model :core:ui --> :core:model
:feature:interests -.-> :core:data :feature:bookmarks:api --> :core:navigation
:feature:interests -.-> :core:designsystem :feature:bookmarks:impl -.-> :core:data
:feature:interests -.-> :core:domain :feature:bookmarks:impl -.-> :core:designsystem
:feature:interests -.-> :core:ui :feature:bookmarks:impl -.-> :core:ui
:feature:bookmarks:impl -.-> :feature:bookmarks:api
:feature:bookmarks:impl -.-> :feature:topic:api
:feature:topic:api -.-> :core:designsystem
:feature:topic:api --> :core:navigation
:feature:topic:api -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000; classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;

@ -15,17 +15,18 @@
*/ */
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature.impl)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
} }
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks" namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.impl"
} }
dependencies { dependencies {
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.feature.bookmarks.api)
implementation(projects.feature.topic.api)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -36,6 +36,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.testing.TestLifecycleOwner import androidx.lifecycle.testing.TestLifecycleOwner
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -64,7 +65,7 @@ class BookmarksScreenTest {
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_bookmarks_loading), composeTestRule.activity.resources.getString(R.string.feature_bookmarks_api_loading),
) )
.assertExists() .assertExists()
} }
@ -161,13 +162,13 @@ class BookmarksScreenTest {
composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_empty_error), composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error),
) )
.assertExists() .assertExists()
composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_empty_description), composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description),
) )
.assertExists() .assertExists()
} }

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks.impl
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -74,9 +74,10 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R
@Composable @Composable
internal fun BookmarksRoute( internal fun BookmarksScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -112,8 +113,8 @@ internal fun BookmarksScreen(
undoBookmarkRemoval: () -> Unit = {}, undoBookmarkRemoval: () -> Unit = {},
clearUndoState: () -> Unit = {}, clearUndoState: () -> Unit = {},
) { ) {
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_removed) val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_undo) val undoText = stringResource(id = R.string.feature_bookmarks_api_undo)
LaunchedEffect(shouldDisplayUndoBookmark) { LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) {
@ -155,7 +156,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
.fillMaxWidth() .fillMaxWidth()
.wrapContentSize() .wrapContentSize()
.testTag("forYou:loading"), .testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.feature_bookmarks_loading), contentDesc = stringResource(id = R.string.feature_bookmarks_api_loading),
) )
} }
@ -228,7 +229,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
val iconTint = LocalTintTheme.current.iconTint val iconTint = LocalTintTheme.current.iconTint
Image( Image(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
painter = painterResource(id = R.drawable.feature_bookmarks_img_empty_bookmarks), painter = painterResource(id = R.drawable.feature_bookmarks_api_mg_empty_bookmarks),
colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null, colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null,
contentDescription = null, contentDescription = null,
) )
@ -236,7 +237,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
Text( Text(
text = stringResource(id = R.string.feature_bookmarks_empty_error), text = stringResource(id = R.string.feature_bookmarks_api_empty_error),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -246,7 +247,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(id = R.string.feature_bookmarks_empty_description), text = stringResource(id = R.string.feature_bookmarks_api_empty_description),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks.impl
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf

@ -0,0 +1,49 @@
/*
* Copyright 2025 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.bookmarks.impl.navigation
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.bookmarksEntry(navigator: Navigator) {
entry<BookmarksNavKey> {
val snackbarHostState = LocalSnackbarHostState.current
BookmarksScreen(
onTopicClick = navigator::navigateToTopic,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
},
)
}
}
// TODO: Why is this here?
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("SnackbarHostState state should be initialized at runtime")
}

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks.impl
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData
@ -23,6 +23,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksViewModel
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher

@ -1,38 +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.feature.bookmarks.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
import kotlinx.serialization.Serializable
@Serializable object BookmarksRoute
fun NavController.navigateToBookmarks(navOptions: NavOptions) =
navigate(route = BookmarksRoute, navOptions)
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
}

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M16,2H55C57.209,2 59,3.791 59,6V52"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startX="8"
android:startY="8"
android:endX="56"
android:endY="56"
android:type="linear">
<item android:offset="0" android:color="#FFFFA8FF"/>
<item android:offset="1" android:color="#FFFF8B5E"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M45,10H9C6.791,10 5,11.791 5,14V55.854C5,59.177 8.817,61.051 11.446,59.019L24.554,48.89C25.995,47.777 28.005,47.777 29.446,48.89L42.554,59.019C45.183,61.051 49,59.177 49,55.854V14C49,11.791 47.209,10 45,10Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startX="8"
android:startY="8"
android:endX="56"
android:endY="56"
android:type="linear">
<item android:offset="0" android:color="#FFFFA8FF"/>
<item android:offset="1" android:color="#FFFF8B5E"/>
</gradient>
</aapt:attr>
</path>
</vector>

@ -0,0 +1,57 @@
# `:feature:foryou:api`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :feature
direction TB
subgraph :feature:foryou
direction TB
:feature:foryou:api[api]:::android-library
end
end
subgraph :core
direction TB
:core:navigation[navigation]:::android-library
end
:feature:foryou:api --> :core:navigation
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -0,0 +1,27 @@
/*
* 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.feature.api)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.foryou.api"
}
dependencies {
api(projects.core.navigation)
}

@ -0,0 +1,23 @@
/*
* 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.feature.foryou.api.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
object ForYouNavKey : NavKey

@ -15,11 +15,10 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<string name="feature_foryou_title">For you</string> <string name="feature_foryou_api_title">For you</string>
<string name="feature_foryou_done">Done</string> <string name="feature_foryou_api_done">Done</string>
<string name="feature_foryou_loading">Loading for you…</string> <string name="feature_foryou_api_loading">Loading for you…</string>
<string name="feature_foryou_navigate_up">Navigate up</string> <string name="feature_foryou_api_navigate_up">Navigate up</string>
<string name="feature_foryou_onboarding_guidance_title">What are you interested in?</string> <string name="feature_foryou_api_onboarding_guidance_title">What are you interested in?</string>
<string name="feature_foryou_onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string> <string name="feature_foryou_api_onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string>
</resources> </resources>

@ -1,4 +1,4 @@
# `:feature:foryou` # `:feature:foryou:impl`
## Module dependency graph ## Module dependency graph
@ -11,6 +11,18 @@ config:
nodePlacementStrategy: SIMPLE nodePlacementStrategy: SIMPLE
--- ---
graph TB graph TB
subgraph :feature
direction TB
subgraph :feature:foryou
direction TB
:feature:foryou:api[api]:::android-library
:feature:foryou:impl[impl]:::android-library
end
subgraph :feature:topic
direction TB
:feature:topic:api[api]:::android-library
end
end
subgraph :core subgraph :core
direction TB direction TB
:core:analytics[analytics]:::android-library :core:analytics[analytics]:::android-library
@ -22,14 +34,11 @@ graph TB
:core:designsystem[designsystem]:::android-library :core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library :core:domain[domain]:::android-library
:core:model[model]:::jvm-library :core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library :core:network[network]:::android-library
:core:notifications[notifications]:::android-library :core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library :core:ui[ui]:::android-library
end end
subgraph :feature
direction TB
:feature:foryou[foryou]:::android-feature
end
:core:data -.-> :core:analytics :core:data -.-> :core:analytics
:core:data --> :core:common :core:data --> :core:common
@ -50,11 +59,16 @@ graph TB
:core:ui --> :core:analytics :core:ui --> :core:analytics
:core:ui --> :core:designsystem :core:ui --> :core:designsystem
:core:ui --> :core:model :core:ui --> :core:model
:feature:foryou -.-> :core:data :feature:foryou:api --> :core:navigation
:feature:foryou -.-> :core:designsystem :feature:foryou:impl -.-> :core:designsystem
:feature:foryou -.-> :core:domain :feature:foryou:impl -.-> :core:domain
:feature:foryou -.-> :core:notifications :feature:foryou:impl -.-> :core:notifications
:feature:foryou -.-> :core:ui :feature:foryou:impl -.-> :core:ui
:feature:foryou:impl -.-> :feature:foryou:api
:feature:foryou:impl -.-> :feature:topic:api
:feature:topic:api -.-> :core:designsystem
:feature:topic:api --> :core:navigation
:feature:topic:api -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000; classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;

@ -15,22 +15,23 @@
*/ */
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature.impl)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.roborazzi) alias(libs.plugins.roborazzi)
} }
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.foryou" namespace = "com.google.samples.apps.nowinandroid.feature.foryou.impl"
testOptions.unitTests.isIncludeAndroidResources = true testOptions.unitTests.isIncludeAndroidResources = true
} }
dependencies { dependencies {
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(projects.core.notifications) implementation(projects.core.notifications)
implementation(projects.feature.foryou.api)
implementation(projects.feature.topic.api)
implementation(libs.androidx.activity.compose)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -32,6 +32,7 @@ import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPer
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -45,7 +46,7 @@ class ForYouScreenTest {
private val doneButtonMatcher by lazy { private val doneButtonMatcher by lazy {
hasText( hasText(
composeTestRule.activity.resources.getString(R.string.feature_foryou_done), composeTestRule.activity.resources.getString(R.string.feature_foryou_api_done),
) )
} }
@ -70,7 +71,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
) )
.assertExists() .assertExists()
} }
@ -96,7 +97,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
) )
.assertExists() .assertExists()
} }
@ -200,7 +201,9 @@ class ForYouScreenTest {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = onboardingUiState =
OnboardingUiState.Shown(topics = followableTopicTestData), OnboardingUiState.Shown(
topics = followableTopicTestData,
),
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null, deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
@ -215,7 +218,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
) )
.assertExists() .assertExists()
} }
@ -241,7 +244,7 @@ class ForYouScreenTest {
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
) )
.assertExists() .assertExists()
} }

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou.impl
import android.net.Uri import android.net.Uri
import android.os.Build.VERSION import android.os.Build.VERSION
@ -80,7 +80,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus.Denied import com.google.accompanist.permissions.PermissionStatus.Denied
@ -103,9 +103,10 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
@Composable @Composable
internal fun ForYouScreen( fun ForYouScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(), viewModel: ForYouViewModel = hiltViewModel(),
@ -215,7 +216,7 @@ internal fun ForYouScreen(
targetOffsetY = { fullHeight -> -fullHeight }, targetOffsetY = { fullHeight -> -fullHeight },
) + fadeOut(), ) + fadeOut(),
) { ) {
val loadingContentDescription = stringResource(id = R.string.feature_foryou_loading) val loadingContentDescription = stringResource(id = R.string.feature_foryou_api_loading)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -270,7 +271,7 @@ private fun LazyStaggeredGridScope.onboarding(
item(span = StaggeredGridItemSpan.FullLine, contentType = "onboarding") { item(span = StaggeredGridItemSpan.FullLine, contentType = "onboarding") {
Column(modifier = interestsItemModifier) { Column(modifier = interestsItemModifier) {
Text( Text(
text = stringResource(R.string.feature_foryou_onboarding_guidance_title), text = stringResource(R.string.feature_foryou_api_onboarding_guidance_title),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -278,7 +279,7 @@ private fun LazyStaggeredGridScope.onboarding(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
text = stringResource(R.string.feature_foryou_onboarding_guidance_subtitle), text = stringResource(R.string.feature_foryou_api_onboarding_guidance_subtitle),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 8.dp, start = 24.dp, end = 24.dp), .padding(top = 8.dp, start = 24.dp, end = 24.dp),
@ -304,7 +305,7 @@ private fun LazyStaggeredGridScope.onboarding(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Text( Text(
text = stringResource(R.string.feature_foryou_done), text = stringResource(R.string.feature_foryou_api_done),
) )
} }
} }
@ -433,7 +434,7 @@ fun TopicIcon(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
DynamicAsyncImage( DynamicAsyncImage(
placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder), placeholder = painterResource(R.drawable.feature_foryou_api_ic_icon_placeholder),
imageUrl = imageUrl, imageUrl = imageUrl,
// decorative // decorative
contentDescription = null, contentDescription = null,

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou.impl
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic

@ -0,0 +1,32 @@
/*
* Copyright 2025 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.foryou.impl.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.forYouEntry(navigator: Navigator) {
entry<ForYouNavKey> {
ForYouScreen(
onTopicClick = navigator::navigateToTopic,
)
}
}

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -31,9 +31,8 @@ import com.google.samples.apps.nowinandroid.core.testing.util.captureMultiDevice
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Loading import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.NotShown
import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.NotShown import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.Shown
import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Shown
import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.HiltTestApplication
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.junit.Before import org.junit.Before
@ -97,7 +96,7 @@ class ForYouScreenScreenshotTests {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
@ -194,7 +193,7 @@ class ForYouScreenScreenshotTests {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isSyncing = true, isSyncing = true,
onboardingUiState = Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = Success( feedState = Success(
feed = userNewsResources, feed = userNewsResources,
), ),

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

Loading…
Cancel
Save