Merge remote-tracking branch 'origin/main' into agp-9

pull/1959/head
Simon Marquis 9 months ago
commit 269f445848

@ -79,10 +79,11 @@ jobs:
- name: Update Graphs - name: Update Graphs
run: ./gradlew graphUpdate run: ./gradlew graphUpdate
continue-on-error: true
- name: Check Graphs - name: Check Graphs
id: graphs_verify id: graphs_verify
run: git add -- '**/README.md' && git diff --cached --quiet --exit-code -- '**/README.md' run: git add -- "**/README.md" && git diff --cached --quiet --exit-code -- "**/README.md"
- name: Prevent updating graphs if this is a fork - name: Prevent updating graphs if this is a fork
id: checkfork_graphs id: checkfork_graphs

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

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

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

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

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

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

@ -1,72 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen
/**
* Top-level navigation graph. Navigation is organized as explained at
* https://d.android.com/jetpack/compose/nav-adaptive
*
* The navigation graph defined in this file defines the different top level routes. Navigation
* within each route is handled using state and Back Handlers.
*/
@Composable
fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = ForYouBaseRoute,
modifier = modifier,
) {
forYouSection(
onTopicClick = navController::navigateToTopic,
) {
topicScreen(
showBackButton = true,
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
}
bookmarksScreen(
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}

@ -1,76 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.R as searchR
/**
* Type for the top level destinations in the application. Contains metadata about the destination
* that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
* @param route The route to use when navigating to this destination.
* @param baseRoute The highest ancestor of this destination. Defaults to [route], meaning that
* there is a single destination in that section of the app (no nested destinations).
*/
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
val baseRoute: KClass<*> = route,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
route = ForYouRoute::class,
baseRoute = ForYouBaseRoute::class,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title,
route = BookmarksRoute::class,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests,
route = InterestsRoute::class,
),
}

@ -0,0 +1,73 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR
/**
* Type for the top level navigation items in the application. Contains UI information about the
* current route that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
*/
data class TopLevelNavItem(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
)
val FOR_YOU = TopLevelNavItem(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name,
)
val BOOKMARKS = TopLevelNavItem(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
)
val INTERESTS = TopLevelNavItem(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
)
val TOP_LEVEL_NAV_ITEMS = mapOf(
ForYouNavKey to FOR_YOU,
BookmarksNavKey to BOOKMARKS,
InterestsNavKey(null) to INTERESTS,
)

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

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

@ -1,43 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
const val TOPIC_ID_KEY = "selectedTopicId"
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val route = savedStateHandle.toRoute<InterestsRoute>()
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
key = TOPIC_ID_KEY,
initialValue = route.initialTopicId,
)
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_KEY] = topicId
}
}

@ -1,244 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.VerticalDragHandle
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.TopicViewModel
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.math.max
@Serializable internal object TopicPlaceholderRoute
fun NavGraphBuilder.interestsListDetailScreen() {
composable<InterestsRoute> {
InterestsListDetailScreen()
}
}
@Composable
internal fun InterestsListDetailScreen(
viewModel: Interests2PaneViewModel = hiltViewModel(),
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
InterestsListDetailScreen(
selectedTopicId = selectedTopicId,
onTopicClick = viewModel::onTopicClick,
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
internal fun InterestsListDetailScreen(
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
windowAdaptiveInfo: WindowAdaptiveInfo,
) {
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo),
initialDestinationHistory = listOfNotNull(
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
selectedTopicId != null
},
),
)
val coroutineScope = rememberCoroutineScope()
val paneExpansionState = rememberPaneExpansionState(
anchors = listOf(
PaneExpansionAnchor.Proportion(0f),
PaneExpansionAnchor.Proportion(0.5f),
PaneExpansionAnchor.Proportion(1f),
),
)
ThreePaneScaffoldPredictiveBackHandler(
listDetailNavigator,
BackNavigationBehavior.PopUntilScaffoldValueChange,
)
BackHandler(
paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(0f) &&
listDetailNavigator.isListPaneVisible() &&
listDetailNavigator.isDetailPaneVisible(),
) {
coroutineScope.launch {
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(1f))
}
}
var topicRoute by remember {
val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
}
fun onTopicClickShowDetailPane(topicId: String) {
onTopicClick(topicId)
topicRoute = TopicRoute(id = topicId)
coroutineScope.launch {
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
if (paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(1f)) {
coroutineScope.launch {
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(0f))
}
}
}
val mutableInteractionSource = remember { MutableInteractionSource() }
val minPaneWidth = 300.dp
NavigableListDetailPaneScaffold(
navigator = listDetailNavigator,
listPane = {
AnimatedPane {
Box(
modifier = Modifier.clipToBounds()
.layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
val placeable = measurable.measure(
constraints.copy(
minWidth = minPaneWidth.roundToPx(),
maxWidth = width,
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.placeRelative(
x = 0,
y = 0,
)
}
},
) {
InterestsRoute(
onTopicClick = ::onTopicClickShowDetailPane,
shouldHighlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
)
}
}
},
detailPane = {
AnimatedPane {
Box(
modifier = Modifier.clipToBounds()
.layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
val placeable = measurable.measure(
constraints.copy(
minWidth = minPaneWidth.roundToPx(),
maxWidth = width,
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.placeRelative(
x = constraints.maxWidth -
max(constraints.maxWidth, placeable.width),
y = 0,
)
}
},
) {
AnimatedContent(topicRoute) { route ->
when (route) {
is TopicRoute -> {
TopicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = {
coroutineScope.launch {
listDetailNavigator.navigateBack()
}
},
onTopicClick = ::onTopicClickShowDetailPane,
viewModel = hiltViewModel<TopicViewModel, TopicViewModel.Factory>(
key = route.id,
) { factory ->
factory.create(route.id)
},
)
}
is TopicPlaceholderRoute -> {
TopicDetailPlaceholder()
}
}
}
}
}
},
paneExpansionState = paneExpansionState,
paneExpansionDragHandle = {
VerticalDragHandle(
modifier = Modifier.paneExpansionDraggable(
state = paneExpansionState,
minTouchTargetSize = LocalMinimumInteractiveComponentSize.current,
interactionSource = mutableInteractionSource,
semanticsProperties = paneExpansionState.defaultDragHandleSemantics(),
),
interactionSource = mutableInteractionSource,
)
},
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded

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

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

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

@ -0,0 +1,17 @@
#
# Copyright 2025 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
sdk = 35

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

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

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

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

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

@ -0,0 +1,34 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureApiConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
dependencies {
"api"(project(":core:navigation"))
}
}
}
}

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

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

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

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

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

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

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

@ -0,0 +1,17 @@
#
# Copyright 2025 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
sdk = 35

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

@ -0,0 +1,41 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.hilt)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.navigation"
}
dependencies {
api(libs.androidx.navigation3.runtime)
implementation(libs.androidx.savedstate.compose)
implementation(libs.androidx.lifecycle.viewModel.navigation3)
testImplementation(libs.truth)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.compose.ui.testManifest)
androidTestImplementation(libs.androidx.lifecycle.viewModel.testing)
androidTestImplementation(libs.truth)
}

@ -0,0 +1,102 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.navigation
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
/**
* Create a navigation state that persists config changes and process death.
*/
@Composable
fun rememberNavigationState(
startKey: NavKey,
topLevelKeys: Set<NavKey>,
): NavigationState {
val topLevelStack = rememberNavBackStack(startKey)
val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) }
return remember(startKey, topLevelKeys) {
NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks,
)
}
}
/**
* State holder for navigation state.
*
* @param startKey - the starting navigation key. The user will exit the app through this key.
* @param topLevelStack - the top level back stack. It holds only top level keys.
* @param subStacks - the back stacks for each top level key
*/
class NavigationState(
val startKey: NavKey,
val topLevelStack: NavBackStack<NavKey>,
val subStacks: Map<NavKey, NavBackStack<NavKey>>,
) {
val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() }
val topLevelKeys
get() = subStacks.keys
@get:VisibleForTesting
val currentSubStack: NavBackStack<NavKey>
get() = subStacks[currentTopLevelKey]
?: error("Sub stack for $currentTopLevelKey does not exist")
@get:VisibleForTesting
val currentKey: NavKey by derivedStateOf { currentSubStack.last() }
}
/**
* Convert NavigationState into NavEntries.
*/
@Composable
fun NavigationState.toEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>,
): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = subStacks.mapValues { (_, stack) ->
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
rememberViewModelStoreNavEntryDecorator<NavKey>(),
)
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = decorators,
entryProvider = entryProvider,
)
}
return topLevelStack
.flatMap { decoratedEntries[it] ?: emptyList() }
.toMutableStateList()
}

@ -0,0 +1,91 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.navigation
import androidx.navigation3.runtime.NavKey
/**
* Handles navigation events (forward and back) by updating the navigation state.
*
* @param state - The navigation state that will be updated in response to navigation events.
*/
class Navigator(val state: NavigationState) {
/**
* Navigate to a navigation key
*
* @param key - the navigation key to navigate to.
*/
fun navigate(key: NavKey) {
when (key) {
state.currentTopLevelKey -> clearSubStack()
in state.topLevelKeys -> goToTopLevel(key)
else -> goToKey(key)
}
}
/**
* Go back to the previous navigation key.
*/
fun goBack() {
when (state.currentKey) {
state.startKey -> error("You cannot go back from the start route")
state.currentTopLevelKey -> {
// We're at the base of the current sub stack, go back to the previous top level
// stack.
state.topLevelStack.removeLastOrNull()
}
else -> state.currentSubStack.removeLastOrNull()
}
}
/**
* Go to a non top level key.
*/
private fun goToKey(key: NavKey) {
state.currentSubStack.apply {
// Remove it if it's already in the stack so it's added at the end.
remove(key)
add(key)
}
}
/**
* Go to a top level stack.
*/
private fun goToTopLevel(key: NavKey) {
state.topLevelStack.apply {
if (key == state.startKey) {
// This is the start key. Clear the stack so it's added as the only key.
clear()
} else {
// Remove it if it's already in the stack so it's added at the end.
remove(key)
}
add(key)
}
}
/**
* Clearing all but the root key in the current sub stack.
*/
private fun clearSubStack() {
state.currentSubStack.run {
if (size > 1) subList(1, size).clear()
}
}
}

@ -0,0 +1,256 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
private object TestFirstTopLevelKey : NavKey
private object TestSecondTopLevelKey : NavKey
private object TestThirdTopLevelKey : NavKey
private object TestKeyFirst : NavKey
private object TestKeySecond : NavKey
class NavigatorTest {
private lateinit var navigationState: NavigationState
private lateinit var navigator: Navigator
@Before
fun setup() {
val startKey = TestFirstTopLevelKey
val topLevelStack = NavBackStack<NavKey>(startKey)
val topLevelKeys = listOf(
startKey,
TestSecondTopLevelKey,
TestThirdTopLevelKey,
)
val subStacks = topLevelKeys.associateWith { key -> NavBackStack(key) }
navigationState = NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks,
)
navigator = Navigator(navigationState)
}
@Test
fun testStartKey() {
assertThat(navigationState.startKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testNavigate() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.subStacks[TestFirstTopLevelKey]?.last()).isEqualTo(TestKeyFirst)
}
@Test
fun testNavigateTopLevel() {
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
}
@Test
fun testNavigateSingleTop() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
}
@Test
fun testNavigateTopLevelSingleTop() {
navigator.navigate(TestSecondTopLevelKey)
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
TestKeyFirst,
).inOrder()
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
).inOrder()
}
@Test
fun testSubStack() {
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testMultiStack() {
// add to start stack
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
// navigate to new top level
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// add to new stack
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// go back to start stack
navigator.navigate(TestFirstTopLevelKey)
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testPopOneNonTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun testPopOneTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestSecondTopLevelKey)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)
// remove TopLevel
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun popMultipleNonTopLevel() {
navigator.navigate(TestKeyFirst)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
TestKeyFirst,
TestKeySecond,
).inOrder()
navigator.goBack()
navigator.goBack()
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun popMultipleTopLevel() {
// second sub-stack
navigator.navigate(TestSecondTopLevelKey)
navigator.navigate(TestKeyFirst)
assertThat(navigationState.currentSubStack).containsExactly(
TestSecondTopLevelKey,
TestKeyFirst,
).inOrder()
// third sub-stack
navigator.navigate(TestThirdTopLevelKey)
navigator.navigate(TestKeySecond)
assertThat(navigationState.currentSubStack).containsExactly(
TestThirdTopLevelKey,
TestKeySecond,
).inOrder()
repeat(4) {
navigator.goBack()
}
assertThat(navigationState.currentSubStack).containsExactly(
TestFirstTopLevelKey,
).inOrder()
assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)
assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)
}
@Test
fun throwOnEmptyBackStack() {
assertFailsWith<IllegalStateException> {
navigator.goBack()
}
}
}

@ -1,89 +1,12 @@
# Modularization learning journey # Modularization learning journey
In this learning journey you will learn about modularization, and the modularization strategy used In this learning journey you will learn about the modularization strategy used
to create the modules in the Now in Android app. to create modules in the Now in Android app. For the theory behind modularization, check out
[the official guidance](https://developer.android.com/topic/modularization).
**IMPORTANT:** Every module has a dependency graph in its README ([example for the app module](https://github.com/android/nowinandroid/tree/main/app)) which can be useful for understanding the overall structure of the project.
## Overview ## Module types
Modularization is the practice of breaking the concept of a monolithic, one-module codebase into
loosely coupled, self contained modules.
### Benefits of modularization
This offers many benefits, including:
**Scalability** - In a tightly coupled codebase, a single change can trigger a cascade of
alterations. A properly modularized project will embrace
the [separation of concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) principle. This
in turn empowers the contributors with more autonomy while also enforcing architectural patterns.
**Enabling work in parallel** - Modularization helps decrease version control conflicts and enables
more efficient work in parallel for developers in larger teams.
**Ownership** - A module can have a dedicated owner who is responsible for maintaining the code and
tests, fixing bugs, and reviewing changes.
**Encapsulation** - Isolated code is easier to read, understand, test and maintain.
**Reduced build time** - Leveraging Gradles parallel and incremental build can reduce build times.
**Dynamic delivery** - Modularization is a requirement
for [Play Feature Delivery](https://developer.android.com/guide/playcore/feature-delivery) which
allows certain features of your app to be delivered conditionally or downloaded on demand.
**Reusability** - Proper modularization enables opportunities for code sharing and building multiple
apps, across different platforms, from the same foundation.
### Modularization pitfalls
However, modularization is a pattern that can be misused, and there are some gotchas to be aware of
when modularizing an app:
**Too many modules** - each module has an overhead that comes in the form of increased complexity of
the build configuration. This can cause Gradle sync times to increase, and incurs an ongoing
maintenance cost. In addition, adding more modules increases the complexity of the projects Gradle
setup, when compared to a single monolithic module. This can be mitigated by making use of
convention plugins, to extract reusable and composable build configuration into type-safe Kotlin
code. In the Now in Android app, these convention plugins can be found in
the [`build-logic` folder](https://github.com/android/nowinandroid/tree/main/build-logic).
**Not enough modules** - conversely if your modules are few, large and tightly coupled, you end up
with yet another monolith. This means you lose some benefits of modularization. If your module is
bloated and has no single, well defined purpose, you should consider splitting it.
**Too complex** - there is no silver bullet here. In fact it doesnt always make sense to modularize
your project. A dominating factor is the size and relative complexity of the codebase. If your
project is not expected to grow beyond a certain threshold, the scalability and build time gains
wont apply.
## Modularization strategy
Its important to note that there is no single modularization strategy that fits all projects.
However, there are general guidelines that can be followed to ensure you maximize its benefits and
minimize its downsides.
A barebone module is simply a directory with a Gradle build script inside. Usually though, a module
will consist of one or more source sets and possibly a collection of resources or assets. Modules
can be built and tested independently. Due to Gradle's flexibility there are few constraints as to
how you can organize your project. In general, you should strive for low coupling and high cohesion.
* **Low coupling** - Modules should be as independent as possible from one another, so that changes
to one module have zero or minimal impact on other modules. They should not possess knowledge of
the inner workings of other modules.
* **High cohesion** - A module should comprise a collection of code that acts as a system. It should
have clearly defined responsibilities and stay within boundaries of certain domain knowledge. For
example,
the [`core:network` module](https://github.com/android/nowinandroid/tree/main/core/network) in Now
in Android is responsible for making network requests, handling responses from a remote data
source, and supplying data to other modules.
## Types of modules in Now in Android
```mermaid ```mermaid
graph TB graph TB
@ -95,7 +18,7 @@ graph TB
:core:network[network]:::android-library :core:network[network]:::android-library
:core:ui[ui]:::android-library :core:ui[ui]:::android-library
end end
subgraph :feature subgraph :feature
direction TB direction TB
:feature:topic[topic]:::android-feature :feature:topic[topic]:::android-feature
:feature:foryou[foryou]:::android-feature :feature:foryou[foryou]:::android-feature
@ -142,30 +65,29 @@ visualizing dependencies between modules.
The Now in Android app contains the following types of modules: The Now in Android app contains the following types of modules:
* The `app` module - contains app level and scaffolding classes that bind the rest of the codebase, ### The `app` module
such as `MainActivity`, `NiaApp` and app-level controlled navigation. A good example of this is This contains app level and scaffolding classes that bind the rest of the codebase, such as
the navigation setup through `NiaNavHost` and the bottom navigation bar setup `MainActivity`, `NiaApp` and app-level controlled navigation. A good example of this is the navigation setup through `NiaNavHost` and the bottom navigation bar setup through `TopLevelDestination`. The `app` module depends on all `feature` modules and required `core` modules.
through `TopLevelDestination`. The `app` module depends on all `feature` modules and
required `core` modules.
* `feature:` modules - feature specific modules which are scoped to handle a single responsibility ### Feature modules
in the app. These modules can be reused by any app, including test or other flavoured apps, when These are feature-specific modules that handle a single responsibility in the app. For example, the `ForYou` feature handles all content and UI state for the "ForYou" screen. Feature modules aren't Gradle modules themselves, they are split into two submodules:
needed, while still keeping it separated and isolated. If a class is needed only by one `feature`
module, it should remain within that module. If not, it should be extracted into an
appropriate `core` module. A `feature` module should have no dependencies on other feature
modules. They only depend on the `core` modules that they require.
* `core:` modules - common library modules containing auxiliary code and specific dependencies that * `api` - contains navigation keys
need to be shared between other modules in the app. These modules can depend on other core * `impl` - contains everything else
modules, but they shouldnt depend on feature nor app modules.
* Miscellaneous modules - such as `sync`, `benchmark` and `test` modules, as well This approach allows features to navigate to other features by using the target feature's navigation keys. A feature's `api` and `impl` modules can be used by any app, including test or other flavoured apps. If a class is needed only by one feature module, it should remain within that module. If not, it should be placed into an appropriate `core` module.
as `app-nia-catalog` - a catalog app for displaying our design system quickly.
A feature's `api` module should not depend on another feature's `api` or `impl` module. A feature's `impl` should only depend on another feature's `api` module. Both submodules should only depend on the `core` modules that they require.
## Modules ### Core modules
These are common library modules containing auxiliary code and specific dependencies that
need to be shared between other modules in the app. These modules can depend on other core
modules, but they shouldnt depend on feature nor app modules.
### Miscellaneous modules
For example, `sync`, `benchmark` and `test` modules, as well as `app-nia-catalog` - a catalog app for displaying our design system quickly.
Using the above modularization strategy, the Now in Android app has the following modules: ## Examples
<table> <table>
<tr> <tr>
@ -186,15 +108,28 @@ Using the above modularization strategy, the Now in Android app has the followin
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>feature:1,</code><br> <td><code>feature:1:api,</code><br>
<code>feature:2</code><br> <code>feature:2:api</code><br>
...
</td>
<td>Navigation keys and functions that other features can use to navigate to this feature.<br><br>
For example: The <code>:topic:api</code> module exposes a <code>Navigator.navigateToTopic</code> function that the
<code>:interests:impl</code> module uses to navigate from the <code>InterestsScreen</code> to the <code>TopicScreen</code> when
a topic is clicked.
</td>
<td><code>TopicNavKey</code>
</td>
</tr>
<tr>
<td><code>feature:1:impl,</code><br>
<code>feature:2:impl</code><br>
... ...
</td> </td>
<td>Functionality associated with a specific feature or user journey. Typically contains UI components and ViewModels which read data from other modules.<br> <td>Functionality associated with a specific feature or user journey. Typically contains UI components and ViewModels which read data from other modules.<br>
Examples include:<br> Examples include:<br>
<ul> <ul>
<li><a href="https://github.com/android/nowinandroid/tree/main/feature/topic"><code>feature:topic</code></a> displays information about a topic on the TopicScreen.</li> <li><a href="https://github.com/android/nowinandroid/tree/main/feature/topic/impl"><code>feature:topic:impl</code></a> displays information about a topic on the TopicScreen.</li>
<li><a href="https://github.com/android/nowinandroid/tree/main/feature/foryou"><code>feature:foryou</code></a> which displays the user's news feed, and onboarding during first run, on the For You screen.</li> <li><a href="https://github.com/android/nowinandroid/tree/main/feature/foryou/impl"><code>feature:foryou:impl</code></a> which displays the user's news feed, and onboarding during first run, on the For You screen.</li>
</ul> </ul>
</td> </td>
<td><code>TopicScreen</code><br> <td><code>TopicScreen</code><br>
@ -283,11 +218,12 @@ Using the above modularization strategy, the Now in Android app has the followin
</tr> </tr>
</table> </table>
## Dependency graphs
Each module has its own `README.md` file containing a module graph (e.g. [`:app` module graph](../app/README.md#module-dependency-graph)). Each module has its own `README.md` file containing a module graph (e.g. [`:app` module graph](../app/README.md#module-dependency-graph)).
When modules dependencies change, module graphs are automatically updated by the [Build.yaml](../.github/workflows/Build.yaml) workflow. When modules dependencies change, module graphs are automatically updated by the [Build.yaml](../.github/workflows/Build.yaml) workflow.
You can also manually update the graphs by running the `graphUpdate` task. You can also manually update the graphs by running the `graphUpdate` task.
## Modularization in Now in Android ## Further considerations
Our modularization approach was defined taking into account the “Now in Android” project roadmap, upcoming work and new features. Additionally, our aim this time around was to find the right balance between overmodularizing a relatively small app and using this opportunity to showcase a modularization pattern fit for a much larger codebase, closer to real world apps in production environments. Our modularization approach was defined taking into account the “Now in Android” project roadmap, upcoming work and new features. Additionally, our aim this time around was to find the right balance between overmodularizing a relatively small app and using this opportunity to showcase a modularization pattern fit for a much larger codebase, closer to real world apps in production environments.

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

@ -0,0 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature.api)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api"
}

@ -0,0 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
object BookmarksNavKey : NavKey

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

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

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

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

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

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

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

@ -0,0 +1,49 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.bookmarksEntry(navigator: Navigator) {
entry<BookmarksNavKey> {
val snackbarHostState = LocalSnackbarHostState.current
BookmarksScreen(
onTopicClick = navigator::navigateToTopic,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
},
)
}
}
// TODO: Why is this here?
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("SnackbarHostState state should be initialized at runtime")
}

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

@ -1,38 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.bookmarks.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
import kotlinx.serialization.Serializable
@Serializable object BookmarksRoute
fun NavController.navigateToBookmarks(navOptions: NavOptions) =
navigate(route = BookmarksRoute, navOptions)
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
}

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

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

@ -0,0 +1,27 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature.api)
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.foryou.api"
}
dependencies {
api(projects.core.navigation)
}

@ -0,0 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.foryou.api.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
object ForYouNavKey : NavKey

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

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

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

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

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

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

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

@ -0,0 +1,32 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
fun EntryProviderScope<NavKey>.forYouEntry(navigator: Navigator) {
entry<ForYouNavKey> {
ForYouScreen(
onTopicClick = navigator::navigateToTopic,
)
}
}

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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

Loading…
Cancel
Save