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
commit_message: "🤖 Updates baselines for Dependency Guard"
- name: Update Graphs
run: ./gradlew graphUpdate
- name: Check Graphs
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
continue-on-error: false
if: steps.graphs_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
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'
uses: stefanzweifel/git-auto-commit-action@v5
with:
file_pattern: '**/README.md'
disable_globbing: true
commit_message: "🤖 Updates graphs"
# See https://github.com/android/nowinandroid/issues/2005
# - name: Update Graphs
# run: ./gradlew graphUpdate
#
# - name: Check Graphs
# 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
# continue-on-error: false
# if: steps.graphs_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
# 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'
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# file_pattern: '**/README.md'
# disable_globbing: true
# commit_message: "🤖 Updates graphs"
- name: Run all local screenshot tests (Roborazzi)
id: screenshotsverify

@ -12,47 +12,49 @@ androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.5.0
androidx.collection:collection-ktx:1.5.0
androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.10.0-alpha02
androidx.compose.animation:animation-core-android:1.10.0-alpha02
androidx.compose.animation:animation-core:1.10.0-alpha02
androidx.compose.animation:animation:1.10.0-alpha02
androidx.compose.foundation:foundation-android:1.10.0-alpha02
androidx.compose.foundation:foundation-layout-android:1.10.0-alpha02
androidx.compose.foundation:foundation-layout:1.10.0-alpha02
androidx.compose.foundation:foundation:1.10.0-alpha02
androidx.compose.material3.adaptive:adaptive-android:1.2.0-beta01
androidx.compose.material3.adaptive:adaptive:1.2.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha03
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha03
androidx.compose.material3:material3-android:1.5.0-alpha03
androidx.compose.material3:material3:1.5.0-alpha03
androidx.compose.animation:animation-android:1.10.0-alpha04
androidx.compose.animation:animation-core-android:1.10.0-alpha04
androidx.compose.animation:animation-core:1.10.0-alpha04
androidx.compose.animation:animation:1.10.0-alpha04
androidx.compose.foundation:foundation-android:1.10.0-alpha04
androidx.compose.foundation:foundation-layout-android:1.10.0-alpha04
androidx.compose.foundation:foundation-layout:1.10.0-alpha04
androidx.compose.foundation:foundation:1.10.0-alpha04
androidx.compose.material3.adaptive:adaptive-android:1.2.0-beta03
androidx.compose.material3.adaptive:adaptive:1.2.0-beta03
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04
androidx.compose.material3:material3-android: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: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-ripple-android:1.10.0-alpha02
androidx.compose.material:material-ripple:1.10.0-alpha02
androidx.compose.runtime:runtime-android:1.10.0-alpha02
androidx.compose.runtime:runtime-annotation-android:1.10.0-alpha02
androidx.compose.runtime:runtime-annotation:1.10.0-alpha02
androidx.compose.runtime:runtime-saveable-android:1.10.0-alpha02
androidx.compose.runtime:runtime-saveable:1.10.0-alpha02
androidx.compose.runtime:runtime:1.10.0-alpha02
androidx.compose.ui:ui-android:1.10.0-alpha02
androidx.compose.ui:ui-geometry-android:1.10.0-alpha02
androidx.compose.ui:ui-geometry:1.10.0-alpha02
androidx.compose.ui:ui-graphics-android:1.10.0-alpha02
androidx.compose.ui:ui-graphics:1.10.0-alpha02
androidx.compose.ui:ui-text-android:1.10.0-alpha02
androidx.compose.ui:ui-text:1.10.0-alpha02
androidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha02
androidx.compose.ui:ui-tooling-preview:1.10.0-alpha02
androidx.compose.ui:ui-unit-android:1.10.0-alpha02
androidx.compose.ui:ui-unit:1.10.0-alpha02
androidx.compose.ui:ui-util-android:1.10.0-alpha02
androidx.compose.ui:ui-util:1.10.0-alpha02
androidx.compose.ui:ui:1.10.0-alpha02
androidx.compose:compose-bom-alpha:2025.08.01
androidx.compose.material:material-ripple-android:1.10.0-alpha04
androidx.compose.material:material-ripple:1.10.0-alpha04
androidx.compose.runtime:runtime-android:1.10.0-alpha04
androidx.compose.runtime:runtime-annotation-android:1.10.0-alpha04
androidx.compose.runtime:runtime-annotation:1.10.0-alpha04
androidx.compose.runtime:runtime-retain-android:1.10.0-alpha04
androidx.compose.runtime:runtime-retain:1.10.0-alpha04
androidx.compose.runtime:runtime-saveable-android:1.10.0-alpha04
androidx.compose.runtime:runtime-saveable:1.10.0-alpha04
androidx.compose.runtime:runtime:1.10.0-alpha04
androidx.compose.ui:ui-android:1.10.0-alpha04
androidx.compose.ui:ui-geometry-android:1.10.0-alpha04
androidx.compose.ui:ui-geometry:1.10.0-alpha04
androidx.compose.ui:ui-graphics-android:1.10.0-alpha04
androidx.compose.ui:ui-graphics:1.10.0-alpha04
androidx.compose.ui:ui-text-android:1.10.0-alpha04
androidx.compose.ui:ui-text:1.10.0-alpha04
androidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha04
androidx.compose.ui:ui-tooling-preview:1.10.0-alpha04
androidx.compose.ui:ui-unit-android:1.10.0-alpha04
androidx.compose.ui:ui-unit:1.10.0-alpha04
androidx.compose.ui:ui-util-android:1.10.0-alpha04
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.core:core-ktx:1.16.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.interpolator:interpolator: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-jvm:2.10.0-alpha03
androidx.lifecycle:lifecycle-common:2.10.0-alpha03
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0-alpha03
androidx.lifecycle:lifecycle-livedata-core:2.10.0-alpha03
androidx.lifecycle:lifecycle-livedata:2.10.0-alpha03
androidx.lifecycle:lifecycle-process:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-compose:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel:2.10.0-alpha03
androidx.lifecycle:lifecycle-common-java8:2.9.4
androidx.lifecycle:lifecycle-common-jvm:2.9.4
androidx.lifecycle:lifecycle-common:2.9.4
androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4
androidx.lifecycle:lifecycle-livedata-core:2.9.4
androidx.lifecycle:lifecycle-livedata:2.9.4
androidx.lifecycle:lifecycle-process:2.9.4
androidx.lifecycle:lifecycle-runtime-android:2.9.4
androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4
androidx.lifecycle:lifecycle-runtime-compose:2.9.4
androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4
androidx.lifecycle:lifecycle-runtime-ktx:2.9.4
androidx.lifecycle:lifecycle-runtime:2.9.4
androidx.lifecycle:lifecycle-viewmodel-android:2.9.4
androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4
androidx.lifecycle:lifecycle-viewmodel:2.9.4
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-beta01
androidx.print:print:1.0.0
androidx.profileinstaller:profileinstaller:1.4.0
androidx.savedstate:savedstate-android:1.4.0-alpha03
androidx.savedstate:savedstate-compose-android:1.4.0-alpha03
androidx.savedstate:savedstate-compose:1.4.0-alpha03
androidx.savedstate:savedstate-ktx:1.4.0-alpha03
androidx.savedstate:savedstate:1.4.0-alpha03
androidx.savedstate:savedstate-android:1.3.2
androidx.savedstate:savedstate-compose-android:1.3.2
androidx.savedstate:savedstate-compose:1.3.2
androidx.savedstate:savedstate-ktx:1.3.2
androidx.savedstate:savedstate:1.3.2
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx: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-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.2.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3

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

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

@ -1,6 +1,6 @@
androidx.activity:activity-compose:1.10.1
androidx.activity:activity-ktx:1.10.1
androidx.activity:activity:1.10.1
androidx.activity:activity-compose:1.12.0
androidx.activity:activity-ktx:1.12.0
androidx.activity:activity:1.12.0
androidx.annotation:annotation-experimental:1.5.1
androidx.annotation:annotation-jvm: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-ktx:1.5.0
androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.10.0-alpha02
androidx.compose.animation:animation-core-android:1.10.0-alpha02
androidx.compose.animation:animation-core:1.10.0-alpha02
androidx.compose.animation:animation:1.10.0-alpha02
androidx.compose.foundation:foundation-android:1.10.0-alpha02
androidx.compose.foundation:foundation-layout-android:1.10.0-alpha02
androidx.compose.foundation:foundation-layout:1.10.0-alpha02
androidx.compose.foundation:foundation:1.10.0-alpha02
androidx.compose.material3.adaptive:adaptive-android:1.2.0-beta01
androidx.compose.material3.adaptive:adaptive-layout-android:1.2.0-beta01
androidx.compose.material3.adaptive:adaptive-layout:1.2.0-beta01
androidx.compose.material3.adaptive:adaptive-navigation-android:1.2.0-beta01
androidx.compose.material3.adaptive:adaptive-navigation:1.2.0-beta01
androidx.compose.material3.adaptive:adaptive:1.2.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha03
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha03
androidx.compose.material3:material3-android:1.5.0-alpha03
androidx.compose.material3:material3-window-size-class-android:1.5.0-alpha03
androidx.compose.material3:material3-window-size-class:1.5.0-alpha03
androidx.compose.material3:material3:1.5.0-alpha03
androidx.compose.animation:animation-android:1.10.0-beta02
androidx.compose.animation:animation-core-android:1.10.0-beta02
androidx.compose.animation:animation-core:1.10.0-beta02
androidx.compose.animation:animation:1.10.0-beta02
androidx.compose.foundation:foundation-android:1.10.0-beta02
androidx.compose.foundation:foundation-layout-android:1.10.0-beta02
androidx.compose.foundation:foundation-layout:1.10.0-beta02
androidx.compose.foundation:foundation:1.10.0-beta02
androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation3-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation3:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive:1.3.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04
androidx.compose.material3:material3-android:1.5.0-alpha04
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: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-ripple-android:1.10.0-alpha02
androidx.compose.material:material-ripple:1.10.0-alpha02
androidx.compose.runtime:runtime-android:1.10.0-alpha02
androidx.compose.runtime:runtime-annotation-android:1.10.0-alpha02
androidx.compose.runtime:runtime-annotation:1.10.0-alpha02
androidx.compose.runtime:runtime-saveable-android:1.10.0-alpha02
androidx.compose.runtime:runtime-saveable:1.10.0-alpha02
androidx.compose.runtime:runtime-tracing:1.10.0-alpha02
androidx.compose.runtime:runtime:1.10.0-alpha02
androidx.compose.ui:ui-android:1.10.0-alpha02
androidx.compose.ui:ui-geometry-android:1.10.0-alpha02
androidx.compose.ui:ui-geometry:1.10.0-alpha02
androidx.compose.ui:ui-graphics-android:1.10.0-alpha02
androidx.compose.ui:ui-graphics:1.10.0-alpha02
androidx.compose.ui:ui-text-android:1.10.0-alpha02
androidx.compose.ui:ui-text:1.10.0-alpha02
androidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha02
androidx.compose.ui:ui-tooling-preview:1.10.0-alpha02
androidx.compose.ui:ui-unit-android:1.10.0-alpha02
androidx.compose.ui:ui-unit:1.10.0-alpha02
androidx.compose.ui:ui-util-android:1.10.0-alpha02
androidx.compose.ui:ui-util:1.10.0-alpha02
androidx.compose.ui:ui:1.10.0-alpha02
androidx.compose:compose-bom-alpha:2025.08.01
androidx.compose.material:material-ripple-android:1.10.0-alpha04
androidx.compose.material:material-ripple:1.10.0-alpha04
androidx.compose.runtime:runtime-android:1.10.0-beta02
androidx.compose.runtime:runtime-annotation-android:1.10.0-beta02
androidx.compose.runtime:runtime-annotation:1.10.0-beta02
androidx.compose.runtime:runtime-retain-android:1.10.0-beta02
androidx.compose.runtime:runtime-retain:1.10.0-beta02
androidx.compose.runtime:runtime-saveable-android:1.10.0-beta02
androidx.compose.runtime:runtime-saveable:1.10.0-beta02
androidx.compose.runtime:runtime-tracing:1.10.0-beta02
androidx.compose.runtime:runtime:1.10.0-beta02
androidx.compose.ui:ui-android:1.10.0-beta02
androidx.compose.ui:ui-geometry-android:1.10.0-beta02
androidx.compose.ui:ui-geometry:1.10.0-beta02
androidx.compose.ui:ui-graphics-android:1.10.0-beta02
androidx.compose.ui:ui-graphics:1.10.0-beta02
androidx.compose.ui:ui-text-android:1.10.0-beta02
androidx.compose.ui:ui-text:1.10.0-beta02
androidx.compose.ui:ui-tooling-preview-android:1.10.0-beta02
androidx.compose.ui:ui-tooling-preview:1.10.0-beta02
androidx.compose.ui:ui-unit-android:1.10.0-beta02
androidx.compose.ui:ui-unit:1.10.0-beta02
androidx.compose.ui:ui-util-android:1.10.0-beta02
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:1.1.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.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
androidx.datastore:datastore-android:1.1.1
androidx.datastore:datastore-core-android:1.1.1
androidx.datastore:datastore-core-okio-jvm:1.1.1
androidx.datastore:datastore-core-okio:1.1.1
androidx.datastore:datastore-core:1.1.1
androidx.datastore:datastore-preferences-android:1.1.1
androidx.datastore:datastore-preferences-core-jvm:1.1.1
androidx.datastore:datastore-preferences-core:1.1.1
androidx.datastore:datastore-preferences:1.1.1
androidx.datastore:datastore:1.1.1
androidx.datastore:datastore-android:1.2.0
androidx.datastore:datastore-core-android:1.2.0
androidx.datastore:datastore-core-okio-jvm:1.2.0
androidx.datastore:datastore-core-okio:1.2.0
androidx.datastore:datastore-core:1.2.0
androidx.datastore:datastore-preferences-android:1.2.0
androidx.datastore:datastore-preferences-core-android:1.2.0
androidx.datastore:datastore-preferences-core:1.2.0
androidx.datastore:datastore-preferences-external-protobuf:1.2.0
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.drawerlayout:drawerlayout: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:1.0.1
androidx.hilt:hilt-common:1.2.0
androidx.hilt:hilt-navigation-compose:1.2.0
androidx.hilt:hilt-navigation:1.2.0
androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0-alpha02
androidx.hilt:hilt-lifecycle-viewmodel:1.3.0-alpha02
androidx.hilt:hilt-work:1.2.0
androidx.interpolator:interpolator: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-jvm:2.10.0-alpha03
androidx.lifecycle:lifecycle-common:2.10.0-alpha03
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0-alpha03
androidx.lifecycle:lifecycle-livedata-core:2.10.0-alpha03
androidx.lifecycle:lifecycle-livedata:2.10.0-alpha03
androidx.lifecycle:lifecycle-process:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-compose:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0-alpha03
androidx.lifecycle:lifecycle-runtime:2.10.0-alpha03
androidx.lifecycle:lifecycle-service:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0-alpha03
androidx.lifecycle:lifecycle-viewmodel:2.10.0-alpha03
androidx.lifecycle:lifecycle-common-java8:2.10.0
androidx.lifecycle:lifecycle-common-jvm:2.10.0
androidx.lifecycle:lifecycle-common:2.10.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0
androidx.lifecycle:lifecycle-livedata-core:2.10.0
androidx.lifecycle:lifecycle-livedata:2.10.0
androidx.lifecycle:lifecycle-process:2.10.0
androidx.lifecycle:lifecycle-runtime-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
androidx.lifecycle:lifecycle-runtime:2.10.0
androidx.lifecycle:lifecycle-service:2.10.0
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0
androidx.lifecycle:lifecycle-viewmodel-navigation3-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0
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.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-beta01
androidx.navigation:navigation-common-ktx:2.8.5
androidx.navigation:navigation-common:2.8.5
androidx.navigation:navigation-compose:2.8.5
androidx.navigation:navigation-runtime-ktx:2.8.5
androidx.navigation:navigation-runtime:2.8.5
androidx.navigation3:navigation3-runtime-android:1.0.0
androidx.navigation3:navigation3-runtime:1.0.0
androidx.navigation3:navigation3-ui-android:1.0.0
androidx.navigation3:navigation3-ui:1.0.0
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.privacysandbox.ads:ads-adservices-java: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-runtime-android:2.8.3
androidx.room:room-runtime:2.8.3
androidx.savedstate:savedstate-android:1.4.0-alpha03
androidx.savedstate:savedstate-compose-android:1.4.0-alpha03
androidx.savedstate:savedstate-compose:1.4.0-alpha03
androidx.savedstate:savedstate-ktx:1.4.0-alpha03
androidx.savedstate:savedstate:1.4.0-alpha03
androidx.savedstate:savedstate-android:1.4.0
androidx.savedstate:savedstate-compose-android:1.4.0
androidx.savedstate:savedstate-compose:1.4.0
androidx.savedstate:savedstate-ktx:1.4.0
androidx.savedstate:savedstate:1.4.0
androidx.sqlite:sqlite-android:2.6.1
androidx.sqlite:sqlite-framework-android: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.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window:window-core-android:1.4.0
androidx.window:window-core:1.4.0
androidx.window:window:1.4.0
androidx.window:window-core-android:1.5.0
androidx.window:window-core:1.5.0
androidx.window:window:1.5.0
androidx.work:work-runtime-ktx:2.10.0
androidx.work:work-runtime:2.10.0
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.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.9.0
com.squareup.okio:okio:3.9.0
com.squareup.okio:okio-jvm:3.9.1
com.squareup.okio:okio:3.9.1
com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0
com.squareup.retrofit2:retrofit:2.11.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
javax.inject:javax.inject:1
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-jdk7: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'
targetSdkVersion:'35'
targetSdkVersion:'36'
uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
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: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon=''
uses-library-not-required:'android.ext.adservices'
uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label=''
uses-feature: name='android.hardware.faketouch'
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.ui.semantics.SemanticsActions.ScrollBy
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected
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.model.data.Topic
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.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as FeatureSearchR
import com.google.samples.apps.nowinandroid.feature.settings.impl.R as SettingsR
/**
* Tests all the navigation flows that are handled by the navigation library.
@ -83,15 +86,15 @@ class NavigationTest {
lateinit var newsRepository: NewsRepository
// The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title)
private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_interests)
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_title)
private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_api_interests)
private val sampleTopic = "Headlines"
private val appName by composeTestRule.stringResource(R.string.app_name)
private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_title)
private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_top_app_bar_action_icon_description)
private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text)
private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_api_title)
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_impl_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_dismiss_dialog_button_text)
@Before
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
fun navigationBar_multipleBackStackInterests() {
composeTestRule.apply {
@ -261,12 +267,14 @@ class NavigationTest {
val topic = runBlocking {
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()
// Verify the topic is still shown
onNodeWithTag("topic:${topic.id}").assertIsDisplayed()
// Switch tab
onNodeWithText(forYou).performClick()
// Come back to Interests
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.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
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.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.theme.GradientColors
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.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.toEntries
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.bookmarksEntry
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
fun NiaApp(
@ -83,8 +92,7 @@ fun NiaApp(
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
val shouldShowGradientBackground = appState.navigationState.currentTopLevelKey == ForYouNavKey
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
NiaBackground(modifier = modifier) {
@ -109,15 +117,17 @@ fun NiaApp(
)
}
}
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
NiaApp(
appState = appState,
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
// TODO: Settings should be a dialog screen
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
}
}
}
@ -126,19 +136,18 @@ fun NiaApp(
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class,
ExperimentalMaterial3AdaptiveApi::class,
)
internal fun NiaApp(
appState: NiaAppState,
snackbarHostState: SnackbarHostState,
showSettingsDialog: Boolean,
onSettingsDismissed: () -> Unit,
onTopAppBarActionClick: () -> Unit,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources
val unreadNavKeys by appState.topLevelNavKeysWithUnreadResources
.collectAsStateWithLifecycle()
val currentDestination = appState.currentDestination
if (showSettingsDialog) {
SettingsDialog(
@ -146,28 +155,31 @@ internal fun NiaApp(
)
}
val snackbarHostState = LocalSnackbarHostState.current
val navigator = remember { Navigator(appState.navigationState) }
NiaNavigationSuiteScaffold(
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
.isRouteInHierarchy(destination.baseRoute)
TOP_LEVEL_NAV_ITEMS.forEach { (navKey, navItem) ->
val hasUnread = unreadNavKeys.contains(navKey)
val selected = navKey == appState.navigationState.currentTopLevelKey
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
onClick = { navigator.navigate(navKey) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
imageVector = navItem.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
imageVector = navItem.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
label = { Text(stringResource(navItem.iconTextId)) },
modifier = Modifier
.testTag("NiaNavItem")
.then(if (hasUnread) Modifier.notificationDot() else Modifier),
@ -205,27 +217,30 @@ internal fun NiaApp(
),
),
) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
// Only show the top app bar on top level destinations.
var shouldShowTopAppBar = false
if (destination != null) {
if (appState.navigationState.currentKey in appState.navigationState.topLevelKeys) {
shouldShowTopAppBar = true
val destination = TOP_LEVEL_NAV_ITEMS[appState.navigationState.currentTopLevelKey]
?: error("Top level nav item not found for ${appState.navigationState.currentTopLevelKey}")
NiaTopAppBar(
titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
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,
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(
containerColor = Color.Transparent,
),
onActionClick = { onTopAppBarActionClick() },
onNavigationClick = { appState.navigateToSearch() },
onNavigationClick = { navigator.navigate(SearchNavKey) },
)
}
@ -239,15 +254,20 @@ internal fun NiaApp(
},
),
) {
NiaNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
},
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
val entryProvider = entryProvider {
forYouEntry(navigator)
bookmarksEntry(navigator)
interestsEntry(navigator)
topicEntry(navigator)
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.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController
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 androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.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.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
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.navigation.TOP_LEVEL_NAV_ITEMS
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -56,18 +44,20 @@ fun rememberNiaAppState(
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState {
NavigationTrackingSideEffect(navController)
val navigationState = rememberNavigationState(ForYouNavKey, TOP_LEVEL_NAV_ITEMS.keys)
NavigationTrackingSideEffect(navigationState)
return remember(
navController,
navigationState,
coroutineScope,
networkMonitor,
userNewsResourceRepository,
timeZoneMonitor,
) {
NiaAppState(
navController = navController,
navigationState = navigationState,
coroutineScope = coroutineScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
@ -78,35 +68,12 @@ fun rememberNiaAppState(
@Stable
class NiaAppState(
val navController: NavHostController,
val navigationState: NavigationState,
coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
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
.map(Boolean::not)
.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
* route.
*/
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
/**
* The top level destinations that have unread news resources.
* The top level nav keys that have unread news resources.
*/
val topLevelDestinationsWithUnreadResources: StateFlow<Set<TopLevelDestination>> =
val topLevelNavKeysWithUnreadResources: StateFlow<Set<NavKey>> =
userNewsResourceRepository.observeAllForFollowedTopics()
.combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->
setOfNotNull(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
ForYouNavKey.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BookmarksNavKey.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}
.stateIn(
@ -144,55 +105,15 @@ class NiaAppState(
SharingStarted.WhileSubscribed(5_000),
TimeZone.currentSystemDefault(),
)
/**
* UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you
* 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
*/
@Composable
private fun NavigationTrackingSideEffect(navController: NavHostController) {
TrackDisposableJank(navController) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
metricsHolder.state?.putState("Navigation", destination.route.toString())
}
navController.addOnDestinationChangedListener(listener)
onDispose {
navController.removeOnDestinationChangedListener(listener)
}
private fun NavigationTrackingSideEffect(navigationState: NavigationState) {
TrackDisposableJank(navigationState.currentKey) { metricsHolder ->
metricsHolder.state?.putState("Navigation", navigationState.currentKey.toString())
onDispose {}
}
}

@ -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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation.NavHostController
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable
import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController
import androidx.navigation3.runtime.NavBackStack
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.TestUserDataRepository
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.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.HiltTestApplication
import kotlinx.coroutines.flow.collect
@ -44,7 +42,6 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Tests [NiaAppState].
@ -68,32 +65,42 @@ class NiaAppStateTest {
// Subject under test.
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
fun niaAppState_currentDestination() = runTest {
var currentDestination: String? = null
val navigationState = testNavigationState()
val navigator = Navigator(navigationState)
composeTestRule.setContent {
val navController = rememberTestNavController()
state = remember(navController) {
state = remember(navigationState) {
NiaAppState(
navController = navController,
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
navigationState = navigationState,
)
}
}
// Update currentDestination whenever it changes
currentDestination = state.currentDestination?.route
assertEquals(ForYouNavKey, state.navigationState.currentTopLevelKey)
assertEquals(ForYouNavKey, state.navigationState.currentKey)
// Navigate to destination b once
LaunchedEffect(Unit) {
navController.setCurrentDestination("b")
}
}
// Navigate to another destination once
navigator.navigate(BookmarksNavKey)
assertEquals("b", currentDestination)
composeTestRule.waitForIdle()
assertEquals(BookmarksNavKey, state.navigationState.currentTopLevelKey)
assertEquals(BookmarksNavKey, state.navigationState.currentKey)
}
@Test
@ -106,21 +113,24 @@ class NiaAppStateTest {
)
}
assertEquals(3, state.topLevelDestinations.size)
assertTrue(state.topLevelDestinations[0].name.contains("for_you", true))
assertTrue(state.topLevelDestinations[1].name.contains("bookmarks", true))
assertTrue(state.topLevelDestinations[2].name.contains("interests", true))
val navigationState = state.navigationState
assertEquals(3, navigationState.topLevelKeys.size)
assertEquals(
setOf(ForYouNavKey, BookmarksNavKey, InterestsNavKey(null)),
navigationState.topLevelKeys,
)
}
@Test
fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
navigationState = testNavigationState(),
)
}
@ -136,11 +146,11 @@ class NiaAppStateTest {
fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
navigationState = testNavigationState(),
)
}
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.designsystem.theme.NiaTheme
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 dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -147,9 +148,7 @@ class SnackbarInsetsScreenshotTests {
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium_noSnackbar",
@ -159,13 +158,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -176,13 +173,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"insets_snackbar_medium_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -193,13 +188,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"insets_snackbar_expanded_expanded",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -209,17 +202,18 @@ class SnackbarInsetsScreenshotTests {
}
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) {
lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) {
scope = rememberCoroutineScope()
@ -259,7 +253,6 @@ class SnackbarInsetsScreenshotTests {
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
@ -280,7 +273,7 @@ class SnackbarInsetsScreenshotTests {
}
scope.launch {
action()
action(snackbarHostState)
}
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.designsystem.theme.NiaTheme
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 dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -120,9 +121,7 @@ class SnackbarScreenshotTests {
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium_noSnackbar",
@ -132,13 +131,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -149,13 +146,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"snackbar_medium_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -166,13 +161,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"snackbar_expanded_expanded",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -182,17 +175,19 @@ class SnackbarScreenshotTests {
}
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) {
lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) {
scope = rememberCoroutineScope()
@ -208,7 +203,6 @@ class SnackbarScreenshotTests {
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
@ -227,7 +221,7 @@ class SnackbarScreenshotTests {
}
scope.launch {
action()
action(snackbarHostState)
}
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
---
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
direction TB
:core:analytics[analytics]:::android-library
@ -22,23 +58,11 @@ graph TB
:core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library
:core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library
:core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library
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
:app[app]:::android-application
@ -49,12 +73,17 @@ graph TB
:app -.-> :core:designsystem
:app -.-> :core:model
:app -.-> :core:ui
:app -.-> :feature:bookmarks
:app -.-> :feature:foryou
:app -.-> :feature:interests
:app -.-> :feature:search
:app -.-> :feature:settings
:app -.-> :feature:topic
:app -.-> :feature:bookmarks:api
:app -.-> :feature:bookmarks:impl
:app -.-> :feature:foryou:api
:app -.-> :feature:foryou:impl
:app -.-> :feature:interests:api
: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
:benchmarks -.->|testedApks| :app
:core:data -.-> :core:analytics
@ -76,28 +105,43 @@ graph TB
:core:ui --> :core:analytics
:core:ui --> :core:designsystem
:core:ui --> :core:model
:feature:bookmarks -.-> :core:data
:feature:bookmarks -.-> :core:designsystem
:feature:bookmarks -.-> :core:ui
:feature:foryou -.-> :core:data
:feature:foryou -.-> :core:designsystem
:feature:foryou -.-> :core:domain
:feature:foryou -.-> :core:notifications
:feature:foryou -.-> :core:ui
:feature:interests -.-> :core:data
:feature:interests -.-> :core:designsystem
:feature:interests -.-> :core:domain
:feature:interests -.-> :core:ui
:feature:search -.-> :core:data
:feature:search -.-> :core:designsystem
:feature:search -.-> :core:domain
:feature:search -.-> :core:ui
:feature:settings -.-> :core:data
:feature:settings -.-> :core:designsystem
:feature:settings -.-> :core:ui
:feature:topic -.-> :core:data
:feature:topic -.-> :core:designsystem
:feature:topic -.-> :core:ui
:feature:bookmarks:api --> :core:navigation
:feature:bookmarks:impl -.-> :core:data
:feature:bookmarks:impl -.-> :core:designsystem
:feature:bookmarks:impl -.-> :core:ui
:feature:bookmarks:impl -.-> :feature:bookmarks:api
:feature:bookmarks:impl -.-> :feature:topic:api
:feature:foryou:api --> :core:navigation
:feature:foryou:impl -.-> :core:designsystem
:feature:foryou:impl -.-> :core:domain
:feature:foryou:impl -.-> :core:notifications
:feature:foryou:impl -.-> :core:ui
:feature:foryou:impl -.-> :feature:foryou:api
:feature:foryou:impl -.-> :feature:topic:api
:feature:interests:api --> :core:navigation
:feature:interests:impl -.-> :core:designsystem
:feature:interests:impl -.-> :core:domain
:feature:interests:impl -.-> :core:ui
:feature:interests:impl -.-> :feature:interests:api
:feature:interests:impl -.-> :feature:topic:api
:feature:search:api -.-> :core:domain
:feature:search:api --> :core:navigation
: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:data
:sync:work -.-> :core:notifications

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

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

@ -36,7 +36,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 35
defaultConfig.targetSdk = 36
@Suppress("UnstableApiUsage")
testOptions.animationsDisabled = true
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.dependencies
class AndroidFeatureConventionPlugin : Plugin<Project> {
class AndroidFeatureImplConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "nowinandroid.hilt")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true
@ -39,14 +38,12 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
"implementation"(project(":core:ui"))
"implementation"(project(":core:designsystem"))
"implementation"(libs.findLibrary("androidx.hilt.navigation.compose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").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("kotlinx.serialization.json").get())
"testImplementation"(libs.findLibrary("androidx.navigation.testing").get())
"androidTestImplementation"(
libs.findLibrary("androidx.lifecycle.runtimeTesting").get(),
)

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

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

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

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

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

@ -35,7 +35,7 @@ internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 35
compileSdk = 36
defaultConfig {
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
@ -11,45 +11,19 @@ config:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
subgraph :feature
direction TB
:core:analytics[analytics]:::android-library
:core:common[common]:::jvm-library
:core:data[data]:::android-library
:core:database[database]:::android-library
: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
subgraph :feature:bookmarks
direction TB
:feature:bookmarks:api[api]:::android-library
end
end
subgraph :feature
subgraph :core
direction TB
:feature:settings[settings]:::android-feature
:core:navigation[navigation]:::android-library
end
:core:data -.-> :core:analytics
: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
:feature:bookmarks: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;

@ -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.
-->
<resources>
<string name="feature_bookmarks_title">Saved</string>
<string name="feature_bookmarks_loading">Loading saved…</string>
<string name="feature_bookmarks_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_removed">Bookmark removed</string>
<string name="feature_bookmarks_undo">UNDO</string>
<string name="feature_bookmarks_api_title">Saved</string>
<string name="feature_bookmarks_api_loading">Loading saved…</string>
<string name="feature_bookmarks_api_empty_error">No saved updates</string>
<string name="feature_bookmarks_api_empty_description">Updates you save will be stored here\nto read later</string>
<string name="feature_bookmarks_api_removed">Bookmark removed</string>
<string name="feature_bookmarks_api_undo">UNDO</string>
</resources>

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

@ -15,17 +15,18 @@
*/
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.jacoco)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks"
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.impl"
}
dependencies {
implementation(projects.core.data)
implementation(projects.feature.bookmarks.api)
implementation(projects.feature.topic.api)
testImplementation(projects.core.testing)

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

@ -14,7 +14,7 @@
* 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.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.PreviewParameter
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.compose.LifecycleEventEffect
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.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R
@Composable
internal fun BookmarksRoute(
internal fun BookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
@ -112,8 +113,8 @@ internal fun BookmarksScreen(
undoBookmarkRemoval: () -> Unit = {},
clearUndoState: () -> Unit = {},
) {
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_undo)
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_api_undo)
LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) {
@ -155,7 +156,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
.fillMaxWidth()
.wrapContentSize()
.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
Image(
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,
contentDescription = null,
)
@ -236,7 +237,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(48.dp))
Text(
text = stringResource(id = R.string.feature_bookmarks_empty_error),
text = stringResource(id = R.string.feature_bookmarks_api_empty_error),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
@ -246,7 +247,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.feature_bookmarks_empty_description),
text = stringResource(id = R.string.feature_bookmarks_api_empty_description),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,

@ -14,7 +14,7 @@
* 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.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.
*/
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.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.ui.NewsFeedUiState.Loading
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.launch
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.
-->
<resources>
<string name="feature_foryou_title">For you</string>
<string name="feature_foryou_done">Done</string>
<string name="feature_foryou_loading">Loading for you…</string>
<string name="feature_foryou_navigate_up">Navigate up</string>
<string name="feature_foryou_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_title">For you</string>
<string name="feature_foryou_api_done">Done</string>
<string name="feature_foryou_api_loading">Loading for you…</string>
<string name="feature_foryou_api_navigate_up">Navigate up</string>
<string name="feature_foryou_api_onboarding_guidance_title">What are you interested in?</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>

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

@ -15,22 +15,23 @@
*/
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.jacoco)
alias(libs.plugins.roborazzi)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.foryou"
namespace = "com.google.samples.apps.nowinandroid.feature.foryou.impl"
testOptions.unitTests.isIncludeAndroidResources = true
}
dependencies {
implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain)
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.robolectric)

@ -14,7 +14,7 @@
* 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.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.userNewsResourcesTestData
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.Test
@ -45,7 +46,7 @@ class ForYouScreenTest {
private val doneButtonMatcher by lazy {
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
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading),
composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
)
.assertExists()
}
@ -96,7 +97,7 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading),
composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
)
.assertExists()
}
@ -200,7 +201,9 @@ class ForYouScreenTest {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(topics = followableTopicTestData),
OnboardingUiState.Shown(
topics = followableTopicTestData,
),
feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
@ -215,7 +218,7 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading),
composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
)
.assertExists()
}
@ -241,7 +244,7 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_foryou_loading),
composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),
)
.assertExists()
}

@ -14,7 +14,7 @@
* 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.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.max
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 com.google.accompanist.permissions.ExperimentalPermissionsApi
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.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
@Composable
internal fun ForYouScreen(
fun ForYouScreen(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(),
@ -215,7 +216,7 @@ internal fun ForYouScreen(
targetOffsetY = { fullHeight -> -fullHeight },
) + fadeOut(),
) {
val loadingContentDescription = stringResource(id = R.string.feature_foryou_loading)
val loadingContentDescription = stringResource(id = R.string.feature_foryou_api_loading)
Box(
modifier = Modifier
.fillMaxWidth()
@ -270,7 +271,7 @@ private fun LazyStaggeredGridScope.onboarding(
item(span = StaggeredGridItemSpan.FullLine, contentType = "onboarding") {
Column(modifier = interestsItemModifier) {
Text(
text = stringResource(R.string.feature_foryou_onboarding_guidance_title),
text = stringResource(R.string.feature_foryou_api_onboarding_guidance_title),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
@ -278,7 +279,7 @@ private fun LazyStaggeredGridScope.onboarding(
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.feature_foryou_onboarding_guidance_subtitle),
text = stringResource(R.string.feature_foryou_api_onboarding_guidance_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, start = 24.dp, end = 24.dp),
@ -304,7 +305,7 @@ private fun LazyStaggeredGridScope.onboarding(
.fillMaxWidth(),
) {
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,
) {
DynamicAsyncImage(
placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder),
placeholder = painterResource(R.drawable.feature_foryou_api_ic_icon_placeholder),
imageUrl = imageUrl,
// decorative
contentDescription = null,

@ -14,7 +14,7 @@
* 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.ViewModel

@ -14,7 +14,7 @@
* 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

@ -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.
*/
package com.google.samples.apps.nowinandroid.feature.foryou
package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.activity.ComponentActivity
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.Success
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.OnboardingUiState.NotShown
import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Shown
import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.NotShown
import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.Shown
import dagger.hilt.android.testing.HiltTestApplication
import org.hamcrest.Matchers
import org.junit.Before
@ -97,7 +96,7 @@ class ForYouScreenScreenshotTests {
NiaTheme {
ForYouScreen(
isSyncing = false,
onboardingUiState = Loading,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -194,7 +193,7 @@ class ForYouScreenScreenshotTests {
NiaTheme {
ForYouScreen(
isSyncing = true,
onboardingUiState = Loading,
onboardingUiState = OnboardingUiState.Loading,
feedState = Success(
feed = userNewsResources,
),

@ -14,7 +14,7 @@
* 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 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