Merge branch 'refs/heads/android-remote-main' into hot-fix-dependencies

Change-Id: If411597e83749126093fed5eb2e0c28ed60f9cb8
pull/1545/head
Jaehwa Noh 1 year ago
commit a39c0d2254

@ -35,7 +35,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true
@ -187,7 +187,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true
@ -222,7 +222,7 @@ jobs:
- name: Display local test coverage (only API 30)
if: matrix.api-level == 30
id: jacoco
uses: madrapps/jacoco-report@v1.6.1
uses: madrapps/jacoco-report@v1.7.0
with:
title: Combined test coverage report
min-coverage-overall: 40

@ -32,7 +32,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true

@ -111,7 +111,8 @@ Examples:
To run the tests execute the following gradle tasks:
- `testDemoDebug` run all local tests against the `demoDebug` variant.
- `testDemoDebug` run all local tests against the `demoDebug` variant. Screenshot tests will fail
(see below for explanation). To avoid this, run `recordRoborazziDemoDebug` prior to running unit tests.
- `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant.
**Note:** You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute
@ -137,7 +138,7 @@ stored in `modulename/src/test/screenshots`.
- `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct
images. These can also be found in `modulename/src/test/screenshots`.
**Note:** The known correct screenshots stored in this repository are recorded on CI using Linux. Other
**Note on failing screenshot tests:** The known correct screenshots stored in this repository are recorded on CI using Linux. Other
platforms may (and probably will) generate slightly different images, making the screenshot tests fail.
When working on a non-Linux platform, a workaround to this is to run `recordRoborazziDemoDebug` on the
`main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only

@ -12,45 +12,45 @@ androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0
androidx.compose.animation:animation-android:1.7.0-beta01
androidx.compose.animation:animation-core-android:1.7.0-beta01
androidx.compose.animation:animation-core:1.7.0-beta01
androidx.compose.animation:animation:1.7.0-beta01
androidx.compose.foundation:foundation-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout:1.7.0-beta01
androidx.compose.foundation:foundation:1.7.0-beta01
androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive:1.0.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01
androidx.compose.material3:material3-android:1.3.0-beta01
androidx.compose.material3:material3:1.3.0-beta01
androidx.compose.material:material-icons-core-android:1.6.3
androidx.compose.material:material-icons-core:1.6.3
androidx.compose.material:material-icons-extended-android:1.6.3
androidx.compose.material:material-icons-extended:1.6.3
androidx.compose.material:material-ripple-android:1.7.0-beta01
androidx.compose.material:material-ripple:1.7.0-beta01
androidx.compose.runtime:runtime-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable:1.7.0-beta01
androidx.compose.runtime:runtime:1.7.0-beta01
androidx.compose.ui:ui-android:1.7.0-beta01
androidx.compose.ui:ui-geometry-android:1.7.0-beta01
androidx.compose.ui:ui-geometry:1.7.0-beta01
androidx.compose.ui:ui-graphics-android:1.7.0-beta01
androidx.compose.ui:ui-graphics:1.7.0-beta01
androidx.compose.ui:ui-text-android:1.7.0-beta01
androidx.compose.ui:ui-text:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview:1.7.0-beta01
androidx.compose.ui:ui-unit-android:1.7.0-beta01
androidx.compose.ui:ui-unit:1.7.0-beta01
androidx.compose.ui:ui-util-android:1.7.0-beta01
androidx.compose.ui:ui-util:1.7.0-beta01
androidx.compose.ui:ui:1.7.0-beta01
androidx.compose:compose-bom:2024.02.02
androidx.compose.animation:animation-android:1.7.0
androidx.compose.animation:animation-core-android:1.7.0
androidx.compose.animation:animation-core:1.7.0
androidx.compose.animation:animation:1.7.0
androidx.compose.foundation:foundation-android:1.7.0
androidx.compose.foundation:foundation-layout-android:1.7.0
androidx.compose.foundation:foundation-layout:1.7.0
androidx.compose.foundation:foundation:1.7.0
androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0
androidx.compose.material3:material3-android:1.3.0
androidx.compose.material3:material3:1.3.0
androidx.compose.material:material-icons-core-android:1.7.0
androidx.compose.material:material-icons-core:1.7.0
androidx.compose.material:material-icons-extended-android:1.7.0
androidx.compose.material:material-icons-extended:1.7.0
androidx.compose.material:material-ripple-android:1.7.0
androidx.compose.material:material-ripple:1.7.0
androidx.compose.runtime:runtime-android:1.7.0
androidx.compose.runtime:runtime-saveable-android:1.7.0
androidx.compose.runtime:runtime-saveable:1.7.0
androidx.compose.runtime:runtime:1.7.0
androidx.compose.ui:ui-android:1.7.0
androidx.compose.ui:ui-geometry-android:1.7.0
androidx.compose.ui:ui-geometry:1.7.0
androidx.compose.ui:ui-graphics-android:1.7.0
androidx.compose.ui:ui-graphics:1.7.0
androidx.compose.ui:ui-text-android:1.7.0
androidx.compose.ui:ui-text:1.7.0
androidx.compose.ui:ui-tooling-preview-android:1.7.0
androidx.compose.ui:ui-tooling-preview:1.7.0
androidx.compose.ui:ui-unit-android:1.7.0
androidx.compose.ui:ui-unit:1.7.0
androidx.compose.ui:ui-util-android:1.7.0
androidx.compose.ui:ui-util:1.7.0
androidx.compose.ui:ui:1.7.0
androidx.compose:compose-bom:2024.09.00
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1
androidx.core:core:1.13.1
@ -61,23 +61,23 @@ androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.1
androidx.graphics:graphics-path:1.0.1
androidx.interpolator:interpolator:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.8.0
androidx.lifecycle:lifecycle-common-jvm:2.8.0
androidx.lifecycle:lifecycle-common:2.8.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.0
androidx.lifecycle:lifecycle-livedata-core:2.8.0
androidx.lifecycle:lifecycle-livedata:2.8.0
androidx.lifecycle:lifecycle-process:2.8.0
androidx.lifecycle:lifecycle-runtime-android:2.8.0
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.0
androidx.lifecycle:lifecycle-runtime-compose:2.8.0
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0
androidx.lifecycle:lifecycle-runtime-ktx:2.8.0
androidx.lifecycle:lifecycle-runtime:2.8.0
androidx.lifecycle:lifecycle-viewmodel-android:2.8.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0
androidx.lifecycle:lifecycle-viewmodel:2.8.0
androidx.lifecycle:lifecycle-common-java8:2.8.3
androidx.lifecycle:lifecycle-common-jvm:2.8.3
androidx.lifecycle:lifecycle-common:2.8.3
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.3
androidx.lifecycle:lifecycle-livedata-core:2.8.3
androidx.lifecycle:lifecycle-livedata:2.8.3
androidx.lifecycle:lifecycle-process:2.8.3
androidx.lifecycle:lifecycle-runtime-android:2.8.3
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.3
androidx.lifecycle:lifecycle-runtime-compose:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx:2.8.3
androidx.lifecycle:lifecycle-runtime:2.8.3
androidx.lifecycle:lifecycle-viewmodel-android:2.8.3
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.3
androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.loader:loader:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.profileinstaller:profileinstaller:1.3.1
@ -91,9 +91,9 @@ androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0-beta02
androidx.window:window-core:1.3.0-beta02
androidx.window:window:1.3.0-beta02
androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0
androidx.window:window:1.3.0
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.51.1
@ -109,10 +109,10 @@ io.coil-kt:coil-compose-base:2.6.0
io.coil-kt:coil-compose:2.6.0
io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.20
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.0.0
org.jetbrains.kotlin:kotlin-stdlib:2.0.20
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3

@ -25,6 +25,7 @@ plugins {
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi)
alias(libs.plugins.kotlin.serialization)
}
android {
@ -103,6 +104,7 @@ dependencies {
implementation(libs.androidx.window.core)
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt)
implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler)
@ -114,6 +116,7 @@ dependencies {
testImplementation(projects.core.dataTest)
testImplementation(libs.hilt.android.testing)
testImplementation(projects.sync.syncTest)
testImplementation(libs.kotlin.test)
testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi)

@ -1,64 +1,64 @@
androidx.activity:activity-compose:1.8.2
androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2
androidx.annotation:annotation-experimental:1.4.0
androidx.annotation:annotation-jvm:1.8.0
androidx.annotation:annotation:1.8.0
androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.8.1
androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0
androidx.compose.animation:animation-android:1.7.0-beta01
androidx.compose.animation:animation-core-android:1.7.0-beta01
androidx.compose.animation:animation-core:1.7.0-beta01
androidx.compose.animation:animation:1.7.0-beta01
androidx.compose.foundation:foundation-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout:1.7.0-beta01
androidx.compose.foundation:foundation:1.7.0-beta01
androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-layout:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive:1.0.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01
androidx.compose.material3:material3-android:1.3.0-beta01
androidx.compose.material3:material3-window-size-class-android:1.3.0-beta01
androidx.compose.material3:material3-window-size-class:1.3.0-beta01
androidx.compose.material3:material3:1.3.0-beta01
androidx.compose.material:material-icons-core-android:1.6.3
androidx.compose.material:material-icons-core:1.6.3
androidx.compose.material:material-icons-extended-android:1.6.3
androidx.compose.material:material-icons-extended:1.6.3
androidx.compose.material:material-ripple-android:1.7.0-beta01
androidx.compose.material:material-ripple:1.7.0-beta01
androidx.compose.runtime:runtime-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable:1.7.0-beta01
androidx.collection:collection-jvm:1.4.2
androidx.collection:collection-ktx:1.4.2
androidx.collection:collection:1.4.2
androidx.compose.animation:animation-android:1.7.0
androidx.compose.animation:animation-core-android:1.7.0
androidx.compose.animation:animation-core:1.7.0
androidx.compose.animation:animation:1.7.0
androidx.compose.foundation:foundation-android:1.7.0
androidx.compose.foundation:foundation-layout-android:1.7.0
androidx.compose.foundation:foundation-layout:1.7.0
androidx.compose.foundation:foundation:1.7.0
androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation:1.0.0
androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0
androidx.compose.material3:material3-android:1.3.0
androidx.compose.material3:material3-window-size-class-android:1.3.0
androidx.compose.material3:material3-window-size-class:1.3.0
androidx.compose.material3:material3:1.3.0
androidx.compose.material:material-icons-core-android:1.7.0
androidx.compose.material:material-icons-core:1.7.0
androidx.compose.material:material-icons-extended-android:1.7.0
androidx.compose.material:material-icons-extended:1.7.0
androidx.compose.material:material-ripple-android:1.7.0
androidx.compose.material:material-ripple:1.7.0
androidx.compose.runtime:runtime-android:1.7.0
androidx.compose.runtime:runtime-saveable-android:1.7.0
androidx.compose.runtime:runtime-saveable:1.7.0
androidx.compose.runtime:runtime-tracing:1.0.0-beta01
androidx.compose.runtime:runtime:1.7.0-beta01
androidx.compose.ui:ui-android:1.7.0-beta01
androidx.compose.ui:ui-geometry-android:1.7.0-beta01
androidx.compose.ui:ui-geometry:1.7.0-beta01
androidx.compose.ui:ui-graphics-android:1.7.0-beta01
androidx.compose.ui:ui-graphics:1.7.0-beta01
androidx.compose.ui:ui-text-android:1.7.0-beta01
androidx.compose.ui:ui-text:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview:1.7.0-beta01
androidx.compose.ui:ui-unit-android:1.7.0-beta01
androidx.compose.ui:ui-unit:1.7.0-beta01
androidx.compose.ui:ui-util-android:1.7.0-beta01
androidx.compose.ui:ui-util:1.7.0-beta01
androidx.compose.ui:ui:1.7.0-beta01
androidx.compose:compose-bom:2024.02.02
androidx.compose.runtime:runtime:1.7.0
androidx.compose.ui:ui-android:1.7.0
androidx.compose.ui:ui-geometry-android:1.7.0
androidx.compose.ui:ui-geometry:1.7.0
androidx.compose.ui:ui-graphics-android:1.7.0
androidx.compose.ui:ui-graphics:1.7.0
androidx.compose.ui:ui-text-android:1.7.0
androidx.compose.ui:ui-text:1.7.0
androidx.compose.ui:ui-tooling-preview-android:1.7.0
androidx.compose.ui:ui-tooling-preview:1.7.0
androidx.compose.ui:ui-unit-android:1.7.0
androidx.compose.ui:ui-unit:1.7.0
androidx.compose.ui:ui-util-android:1.7.0
androidx.compose.ui:ui-util:1.7.0
androidx.compose.ui:ui:1.7.0
androidx.compose:compose-bom:2024.09.00
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1
androidx.core:core-splashscreen:1.0.1
@ -106,11 +106,11 @@ androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.navigation:navigation-common-ktx:2.8.0-alpha06
androidx.navigation:navigation-common:2.8.0-alpha06
androidx.navigation:navigation-compose:2.8.0-alpha06
androidx.navigation:navigation-runtime-ktx:2.8.0-alpha06
androidx.navigation:navigation-runtime:2.8.0-alpha06
androidx.navigation:navigation-common-ktx:2.8.0
androidx.navigation:navigation-common:2.8.0
androidx.navigation:navigation-compose:2.8.0
androidx.navigation:navigation-runtime-ktx:2.8.0
androidx.navigation:navigation-runtime:2.8.0
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
@ -132,9 +132,9 @@ androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0-beta02
androidx.window:window-core:1.3.0-beta02
androidx.window:window:1.3.0-beta02
androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0
androidx.window:window:1.3.0
androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4
@ -203,10 +203,10 @@ io.coil-kt:coil-svg:2.6.0
io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.20
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.0.0
org.jetbrains.kotlin:kotlin-stdlib:2.0.20
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0

@ -47,9 +47,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<data
android:scheme="https"
android:host="www.nowinandroid.apps.samples.google.com" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="www.nowinandroid.apps.samples.google.com" />
</intent-filter>
</activity>

@ -23,7 +23,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@ -55,8 +54,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val TAG = "MainActivity"
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@ -148,7 +145,6 @@ class MainActivity : ComponentActivity() {
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
NiaApp(appState)
}
}

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

@ -16,9 +16,14 @@
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.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
@ -31,25 +36,29 @@ import com.google.samples.apps.nowinandroid.feature.search.R as searchR
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val iconTextId: Int,
val titleTextId: Int,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
route = ForYouRoute::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,
),
}

@ -60,6 +60,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
@ -72,6 +73,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradien
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@ -150,7 +152,7 @@ internal fun NiaApp(
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
.isTopLevelDestinationInHierarchy(destination)
.isRouteInHierarchy(destination.route)
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
@ -198,8 +200,10 @@ internal fun NiaApp(
) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
val shouldShowTopAppBar = destination != null
var shouldShowTopAppBar = false
if (destination != null) {
shouldShowTopAppBar = true
NiaTopAppBar(
titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
@ -266,7 +270,7 @@ private fun Modifier.notificationDot(): Modifier =
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false
it.hasRoute(route)
} ?: false

@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
@ -32,11 +33,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -90,11 +88,10 @@ class NiaAppState(
.currentBackStackEntryAsState().value?.destination
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) {
FOR_YOU_ROUTE -> FOR_YOU
BOOKMARKS_ROUTE -> BOOKMARKS
INTERESTS_ROUTE -> INTERESTS
else -> null
@Composable get() {
return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
currentDestination?.hasRoute(route = topLevelDestination.route) ?: false
}
}
val isOffline = networkMonitor.isOnline

@ -18,19 +18,26 @@ package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import 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 selectedTopicId: StateFlow<String?> =
savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG])
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_ARG] = topicId
savedStateHandle[TOPIC_ID_KEY] = topicId
}
}

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.activity.compose.BackHandler
import androidx.annotation.Keep
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
@ -39,34 +40,26 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE
import com.google.samples.apps.nowinandroid.feature.topic.navigation.createTopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import kotlinx.serialization.Serializable
import java.util.UUID
private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route"
@Serializable internal object TopicPlaceholderRoute
// TODO: Remove @Keep when https://issuetracker.google.com/353898971 is fixed
@Keep
@Serializable internal object DetailPaneNavHostRoute
fun NavGraphBuilder.interestsListDetailScreen() {
composable(
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
type = NavType.StringType
defaultValue = null
nullable = true
},
),
) {
composable<InterestsRoute> {
InterestsListDetailScreen()
}
}
@ -104,8 +97,9 @@ internal fun InterestsListDetailScreen(
listDetailNavigator.navigateBack()
}
var nestedNavHostStartDestination by remember {
mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE)
var nestedNavHostStartRoute by remember {
val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
}
var nestedNavKey by rememberSaveable(
stateSaver = Saver({ it.toString() }, UUID::fromString),
@ -122,11 +116,11 @@ internal fun InterestsListDetailScreen(
// If the detail pane was visible, then use the nestedNavController navigate call
// directly
nestedNavController.navigateToTopic(topicId) {
popUpTo(DETAIL_PANE_NAVHOST_ROUTE)
popUpTo<DetailPaneNavHostRoute>()
}
} else {
// Otherwise, recreate the NavHost entirely, and start at the new destination
nestedNavHostStartDestination = createTopicRoute(topicId)
nestedNavHostStartRoute = TopicRoute(id = topicId)
nestedNavKey = UUID.randomUUID()
}
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
@ -148,15 +142,15 @@ internal fun InterestsListDetailScreen(
key(nestedNavKey) {
NavHost(
navController = nestedNavController,
startDestination = nestedNavHostStartDestination,
route = DETAIL_PANE_NAVHOST_ROUTE,
startDestination = nestedNavHostStartRoute,
route = DetailPaneNavHostRoute::class,
) {
topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane,
)
composable(route = TOPIC_ROUTE) {
composable<TopicPlaceholderRoute> {
TopicDetailPlaceholder()
}
}

@ -17,18 +17,17 @@
package com.google.samples.apps.nowinandroid.ui
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children
/**
* A [DeviceConfigurationOverride] that allows overriding the [windowInsets] available
* to the content under test.
* A [DeviceConfigurationOverride] that overrides the window insets for the contained content.
*/
@Suppress("ktlint:standard:function-naming")
fun DeviceConfigurationOverride.Companion.WindowInsets(
@ -38,10 +37,17 @@ fun DeviceConfigurationOverride.Companion.WindowInsets(
val currentWindowInsets by rememberUpdatedState(windowInsets)
AndroidView(
factory = { context ->
object : FrameLayout(context) {
object : AbstractComposeView(context) {
@Composable
override fun Content() {
currentContentUnderTest()
}
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
children.forEach {
it.dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets())
it.dispatchApplyWindowInsets(
WindowInsets(currentWindowInsets.toWindowInsets()),
)
}
return WindowInsetsCompat.CONSUMED.toWindowInsets()!!
}
@ -52,17 +58,10 @@ fun DeviceConfigurationOverride.Companion.WindowInsets(
*/
@Deprecated("Deprecated in Java")
override fun requestFitSystemWindows() {
dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()!!)
dispatchApplyWindowInsets(WindowInsets(currentWindowInsets.toWindowInsets()!!))
}
}.apply {
addView(
ComposeView(context).apply {
setContent {
currentContentUnderTest()
}
},
)
}
},
update = { with(currentWindowInsets) { it.requestApplyInsets() } },
)
}

@ -14,43 +14,49 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.interests2pane
package com.google.samples.apps.nowinandroid.ui
import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.Posture
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.ForcedSize
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso
import androidx.window.core.layout.WindowSizeClass
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.ui.stringResource
import com.google.samples.apps.nowinandroid.ui.interests2pane.InterestsListDetailScreen
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty
import kotlin.test.assertTrue
import com.google.samples.apps.nowinandroid.feature.topic.R as FeatureTopicR
private const val EXPANDED_WIDTH = "w1200dp-h840dp"
private const val COMPACT_WIDTH = "w412dp-h915dp"
@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
class InterestsListDetailScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@ -64,6 +70,11 @@ class InterestsListDetailScreenTest {
@Inject
lateinit var topicsRepository: TopicsRepository
/** Convenience function for getting all topics during tests, */
private fun getTopics(): List<Topic> = runBlocking {
topicsRepository.getTopics().first().sortedBy { it.name }
}
// The strings used for matching in these tests.
private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest)
private val listPaneTag = "interests:topics"
@ -71,39 +82,18 @@ class InterestsListDetailScreenTest {
private val Topic.testTag
get() = "topic:${this.id}"
// Overrides for device sizes.
private enum class TestDeviceConfig(widthDp: Float, heightDp: Float) {
Compact(412f, 915f),
Expanded(1200f, 840f),
;
val sizeOverride = DeviceConfigurationOverride.ForcedSize(DpSize(widthDp.dp, heightDp.dp))
val adaptiveInfo = WindowAdaptiveInfo(
windowSizeClass = WindowSizeClass.compute(widthDp, heightDp),
windowPosture = Posture(),
)
}
@Before
fun setup() {
hiltRule.inject()
}
/** Convenience function for getting all topics during tests, */
private fun getTopics(): List<Topic> = runBlocking {
topicsRepository.getTopics().first()
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {
composeTestRule.apply {
setContent {
with(TestDeviceConfig.Expanded) {
DeviceConfigurationOverride(override = sizeOverride) {
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
NiaTheme {
InterestsListDetailScreen()
}
}
@ -113,15 +103,12 @@ class InterestsListDetailScreenTest {
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_initialState_showsListPane() {
composeTestRule.apply {
setContent {
with(TestDeviceConfig.Compact) {
DeviceConfigurationOverride(override = sizeOverride) {
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
NiaTheme {
InterestsListDetailScreen()
}
}
@ -131,15 +118,12 @@ class InterestsListDetailScreenTest {
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_topicSelected_updatesDetailPane() {
composeTestRule.apply {
setContent {
with(TestDeviceConfig.Expanded) {
DeviceConfigurationOverride(override = sizeOverride) {
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
NiaTheme {
InterestsListDetailScreen()
}
}
@ -153,15 +137,12 @@ class InterestsListDetailScreenTest {
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_topicSelected_showsTopicDetailPane() {
composeTestRule.apply {
setContent {
with(TestDeviceConfig.Compact) {
DeviceConfigurationOverride(override = sizeOverride) {
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
NiaTheme {
InterestsListDetailScreen()
}
}
@ -175,27 +156,25 @@ class InterestsListDetailScreenTest {
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_backPressFromTopicDetail_leavesInterests() {
var unhandledBackPress = false
composeTestRule.apply {
setContent {
with(TestDeviceConfig.Expanded) {
DeviceConfigurationOverride(override = sizeOverride) {
NiaTheme {
// Back press should not be handled by the two pane layout, and thus
// "fall through" to this BackHandler.
BackHandler {
unhandledBackPress = true
}
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
NiaTheme {
// Back press should not be handled by the two pane layout, and thus
// "fall through" to this BackHandler.
BackHandler {
unhandledBackPress = true
}
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
assertTrue(unhandledBackPress)
@ -203,21 +182,19 @@ class InterestsListDetailScreenTest {
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_backPressFromTopicDetail_showsListPane() {
composeTestRule.apply {
setContent {
with(TestDeviceConfig.Compact) {
DeviceConfigurationOverride(override = sizeOverride) {
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
NiaTheme {
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
onNodeWithTag(listPaneTag).assertIsDisplayed()
@ -226,3 +203,8 @@ class InterestsListDetailScreenTest {
}
}
}
private fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }

@ -28,6 +28,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
pluginManager.apply {
apply("nowinandroid.android.library")
apply("nowinandroid.hilt")
apply("org.jetbrains.kotlin.plugin.serialization")
}
extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true
@ -41,8 +42,11 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("androidx.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
add("implementation", libs.findLibrary("kotlinx.serialization.json").get())
add("testImplementation", libs.findLibrary("androidx.navigation.testing").get())
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
}
}

@ -65,8 +65,7 @@ internal fun Project.configureAndroidCompose(
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
stabilityConfigurationFile = rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
enableStrongSkippingMode = true
stabilityConfigurationFile =
rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
}
}

@ -35,12 +35,12 @@ import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations
import java.io.File
import java.util.Locale
import javax.inject.Inject
@CacheableTask
@ -107,6 +107,10 @@ abstract class CheckBadgingTask : DefaultTask() {
}
}
private fun String.capitalized() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
fun Project.configureBadgingTasks(
baseExtension: BaseExtension,
componentsExtension: ApplicationAndroidComponentsExtension,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 290 B

@ -22,6 +22,7 @@ import kotlinx.datetime.Instant
* A [NewsResource] with additional user information such as whether the user is following the
* news resource's topics and whether they have saved (bookmarked) this news resource.
*/
@ConsistentCopyVisibility
data class UserNewsResource internal constructor(
val id: String,
val title: String,

@ -44,7 +44,10 @@ private const val NEWS_NOTIFICATION_SUMMARY_ID = 1
private const val NEWS_NOTIFICATION_CHANNEL_ID = ""
private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS"
private const val DEEP_LINK_SCHEME_AND_HOST = "https://www.nowinandroid.apps.samples.google.com"
private const val FOR_YOU_PATH = "foryou"
private const val DEEP_LINK_FOR_YOU_PATH = "foryou"
private const val DEEP_LINK_BASE_PATH = "$DEEP_LINK_SCHEME_AND_HOST/$DEEP_LINK_FOR_YOU_PATH"
const val DEEP_LINK_NEWS_RESOURCE_ID_KEY = "linkedNewsResourceId"
const val DEEP_LINK_URI_PATTERN = "$DEEP_LINK_BASE_PATH/{$DEEP_LINK_NEWS_RESOURCE_ID_KEY}"
/**
* Implementation of [Notifier] that displays notifications in the system tray.
@ -161,4 +164,4 @@ private fun Context.newsPendingIntent(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_SCHEME_AND_HOST/$FOR_YOU_PATH/$id".toUri()
private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_BASE_PATH/$id".toUri()

@ -87,7 +87,7 @@ fun LazyStaggeredGridScope.newsFeed(
onTopicClick = onTopicClick,
modifier = Modifier
.padding(horizontal = 8.dp)
.animateItemPlacement(),
.animateItem(),
)
}
}

@ -16,8 +16,15 @@
package com.google.samples.apps.nowinandroid.core.ui
import android.content.ClipData
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.view.View
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.draganddrop.dragAndDropSource
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -45,6 +52,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
@ -77,6 +85,7 @@ import java.util.Locale
* [NewsResource] card used on the following screens: For You, Saved
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NewsResourceCardExpanded(
userNewsResource: UserNewsResource,
@ -88,6 +97,19 @@ fun NewsResourceCardExpanded(
modifier: Modifier = Modifier,
) {
val clickActionLabel = stringResource(R.string.core_ui_card_tap_action)
val sharingLabel = stringResource(R.string.core_ui_feed_sharing)
val sharingContent = stringResource(
R.string.core_ui_feed_sharing_data,
userNewsResource.title,
userNewsResource.url,
)
val dragAndDropFlags = if (VERSION.SDK_INT >= VERSION_CODES.N) {
View.DRAG_FLAG_GLOBAL
} else {
0
}
Card(
onClick = onClick,
shape = RoundedCornerShape(16.dp),
@ -112,7 +134,23 @@ fun NewsResourceCardExpanded(
Row {
NewsResourceTitle(
userNewsResource.title,
modifier = Modifier.fillMaxWidth((.8f)),
modifier = Modifier
.fillMaxWidth((.8f))
.dragAndDropSource {
detectTapGestures(
onLongPress = {
startTransfer(
DragAndDropTransferData(
ClipData.newPlainText(
sharingLabel,
sharingContent,
),
flags = dragAndDropFlags,
),
)
},
)
},
)
Spacer(modifier = Modifier.weight(1f))
BookmarkButton(isBookmarked, onToggleBookmark)

@ -29,4 +29,6 @@
<string name="core_ui_interests_card_follow_button_content_desc">Follow interest</string>
<string name="core_ui_interests_card_unfollow_button_content_desc">Unfollow interest</string>
<string name="core_ui_feed_sharing">Feed sharing</string>
<string name="core_ui_feed_sharing_data">%1$s: %2$s</string>
</resources>

@ -21,16 +21,18 @@ 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
const val BOOKMARKS_ROUTE = "bookmarks_route"
@Serializable object BookmarksRoute
fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(BOOKMARKS_ROUTE, navOptions)
fun NavController.navigateToBookmarks(navOptions: NavOptions) =
navigate(route = BookmarksRoute, navOptions)
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
composable(route = BOOKMARKS_ROUTE) {
composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
}

@ -29,6 +29,7 @@ dependencies {
implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain)
implementation(project(":core:notifications"))
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)

@ -106,7 +106,7 @@ import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable
internal fun ForYouRoute(
internal fun ForYouScreen(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(),

@ -27,8 +27,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@ -55,7 +55,7 @@ class ForYouViewModel @Inject constructor(
userDataRepository.userData.map { !it.shouldHideOnboarding }
val deepLinkedNewsResource = savedStateHandle.getStateFlow<String?>(
key = LINKED_NEWS_RESOURCE_ID,
key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
null,
)
.flatMapLatest { newsResourceId ->
@ -129,7 +129,7 @@ class ForYouViewModel @Inject constructor(
fun onDeepLinkOpened(newsResourceId: String) {
if (newsResourceId == deepLinkedNewsResource.value?.id) {
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = null
savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = null
}
analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId)
viewModelScope.launch {
@ -153,7 +153,7 @@ private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) =
type = "news_deep_link_opened",
extras = listOf(
Param(
key = LINKED_NEWS_RESOURCE_ID,
key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = newsResourceId,
),
),

@ -19,29 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_URI_PATTERN
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouScreen
import kotlinx.serialization.Serializable
const val LINKED_NEWS_RESOURCE_ID = "linkedNewsResourceId"
const val FOR_YOU_ROUTE = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}"
private const val DEEP_LINK_URI_PATTERN =
"https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}"
@Serializable data object ForYouRoute
fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(FOR_YOU_ROUTE, navOptions)
fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute, navOptions)
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable(
route = FOR_YOU_ROUTE,
composable<ForYouRoute>(
deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
),
arguments = listOf(
navArgument(LINKED_NEWS_RESOURCE_ID) { type = NavType.StringType },
navDeepLink {
/**
* This destination has a deep link that enables a specific news resource to be
* opened from a notification (@see SystemTrayNotifier for more). The news resource
* ID is sent in the URI rather than being modelled in the route type because it's
* transient data (stored in SavedStateHandle) that is cleared after the user has
* opened the news resource.
*/
uriPattern = DEEP_LINK_URI_PATTERN
},
),
) {
ForYouRoute(onTopicClick)
ForYouScreen(onTopicClick)
}
}

@ -26,6 +26,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -34,7 +35,6 @@ import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@ -472,7 +472,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(emptyUserData)
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = sampleNewsResources.first().id
savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = sampleNewsResources.first().id
assertEquals(
expected = UserNewsResource(
@ -496,7 +496,7 @@ class ForYouViewModelTest {
type = "news_deep_link_opened",
extras = listOf(
Param(
key = LINKED_NEWS_RESOURCE_ID,
key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = sampleNewsResources.first().id,
),
),

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.domain)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)

@ -19,11 +19,12 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -39,7 +40,14 @@ class InterestsViewModel @Inject constructor(
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null)
// Key used to save and retrieve the currently selected topic id from saved state.
private val selectedTopicIdKey = "selectedTopicIdKey"
private val interestsRoute: InterestsRoute = savedStateHandle.toRoute()
private val selectedTopicId = savedStateHandle.getStateFlow(
key = selectedTopicIdKey,
initialValue = interestsRoute.initialTopicId,
)
val uiState: StateFlow<InterestsUiState> = combine(
selectedTopicId,
@ -58,7 +66,7 @@ class InterestsViewModel @Inject constructor(
}
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId
savedStateHandle[selectedTopicIdKey] = topicId
}
}

@ -17,39 +17,17 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import kotlinx.serialization.Serializable
const val TOPIC_ID_ARG = "topicId"
const val INTERESTS_ROUTE_BASE = "interests_route"
const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}"
@Serializable data class InterestsRoute(
// The ID of the topic which will be initially selected at this destination
val initialTopicId: String? = null,
)
fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) {
val route = if (topicId != null) {
"${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId"
} else {
INTERESTS_ROUTE_BASE
}
navigate(route, navOptions)
}
fun NavGraphBuilder.interestsScreen(
onTopicClick: (String) -> Unit,
fun NavController.navigateToInterests(
initialTopicId: String? = null,
navOptions: NavOptions? = null,
) {
composable(
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
defaultValue = null
nullable = true
type = NavType.StringType
},
),
) {
InterestsRoute(onTopicClick = onTopicClick)
}
navigate(route = InterestsRoute(initialTopicId), navOptions)
}

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.interests
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.testing.invoke
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -25,7 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -33,12 +34,21 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*
* These tests use Robolectric because the subject under test (the ViewModel) uses
* `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`.
*
* TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency.
* See https://issuetracker.google.com/340966212.
*/
@RunWith(RobolectricTestRunner::class)
class InterestsViewModelTest {
@get:Rule
@ -55,7 +65,9 @@ class InterestsViewModelTest {
@Before
fun setup() {
viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
savedStateHandle = SavedStateHandle(
route = InterestsRoute(initialTopicId = testInputTopics[0].topic.id),
),
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
)

@ -27,7 +27,6 @@ android {
dependencies {
implementation(projects.core.data)
implementation(projects.core.domain)
implementation(projects.core.ui)
testImplementation(projects.core.testing)

@ -41,7 +41,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
@ -66,6 +65,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -73,6 +73,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -227,23 +228,31 @@ fun EmptySearchResultBody(
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 24.dp),
)
val interests = stringResource(id = searchR.string.feature_search_interests)
val tryAnotherSearchString = buildAnnotatedString {
append(stringResource(id = searchR.string.feature_search_try_another_search))
append(" ")
withStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
withLink(
LinkAnnotation.Clickable(
tag = "",
linkInteractionListener = {
onInterestsClick()
},
),
) {
pushStringAnnotation(tag = interests, annotation = interests)
append(interests)
withStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
),
) {
append(stringResource(id = searchR.string.feature_search_interests))
}
}
append(" ")
append(stringResource(id = searchR.string.feature_search_to_browse_topics))
}
ClickableText(
Text(
text = tryAnotherSearchString,
style = MaterialTheme.typography.bodyLarge.merge(
TextStyle(
@ -252,13 +261,8 @@ fun EmptySearchResultBody(
),
),
modifier = Modifier
.padding(start = 36.dp, end = 36.dp, bottom = 24.dp)
.clickable {},
) { offset ->
tryAnotherSearchString.getStringAnnotations(start = offset, end = offset)
.firstOrNull()
?.let { onInterestsClick() }
}
.padding(start = 36.dp, end = 36.dp, bottom = 24.dp),
)
}
}

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.data)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)

@ -71,7 +71,7 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
@Composable
internal fun TopicRoute(
internal fun TopicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
@ -28,7 +29,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@ -47,12 +48,10 @@ class TopicViewModel @Inject constructor(
userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle)
val topicId = topicArgs.topicId
val topicId = savedStateHandle.toRoute<TopicRoute>().id
val topicUiState: StateFlow<TopicUiState> = topicUiState(
topicId = topicArgs.topicId,
topicId = topicId,
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
)
@ -63,7 +62,7 @@ class TopicViewModel @Inject constructor(
)
val newsUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicArgs.topicId,
topicId = topicId,
userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository,
)
@ -75,7 +74,7 @@ class TopicViewModel @Inject constructor(
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {
userDataRepository.setTopicIdFollowed(topicArgs.topicId, followed)
userDataRepository.setTopicIdFollowed(topicId, followed)
}
}

@ -16,53 +16,28 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.text.Charsets.UTF_8
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
import kotlinx.serialization.Serializable
private val URL_CHARACTER_ENCODING = UTF_8.name()
@VisibleForTesting
internal const val TOPIC_ID_ARG = "topicId"
const val TOPIC_ROUTE = "topic_route"
internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle) :
this(URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING))
}
@Serializable data class TopicRoute(val id: String)
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
navigate(createTopicRoute(topicId)) {
navigate(route = TopicRoute(topicId)) {
navOptions()
}
}
fun createTopicRoute(topicId: String): String {
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
return "$TOPIC_ROUTE/$encodedId"
}
fun NavGraphBuilder.topicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
composable(
route = "topic_route/{$TOPIC_ID_ARG}",
arguments = listOf(
navArgument(TOPIC_ID_ARG) { type = NavType.StringType },
),
) {
TopicRoute(
composable<TopicRoute> {
TopicScreen(
showBackButton = showBackButton,
onBackClick = onBackClick,
onTopicClick = onTopicClick,

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.testing.invoke
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -25,7 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@ -36,13 +37,22 @@ import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*
* These tests use Robolectric because the subject under test (the ViewModel) uses
* `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`.
*
* TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency.
* * See b/340966212.
*/
@RunWith(RobolectricTestRunner::class)
class TopicViewModelTest {
@get:Rule
@ -60,7 +70,9 @@ class TopicViewModelTest {
@Before
fun setup() {
viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
savedStateHandle = SavedStateHandle(
route = TopicRoute(id = testInputTopics[0].topic.id),
),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository,

@ -2,15 +2,12 @@
accompanist = "0.34.0"
androidDesugarJdkLibs = "2.0.4"
# AGP and tools should be updated together
androidGradlePlugin = "8.4.0"
androidTools = "31.4.1"
androidGradlePlugin = "8.6.0"
androidTools = "31.6.0"
androidxActivity = "1.8.2"
androidxAppCompat = "1.7.0"
androidxBrowser = "1.8.0"
androidxComposeAlpha = "1.7.0-beta01"
androidxComposeBom = "2024.02.02"
androidxComposeMaterial3Adaptive = "1.0.0-beta01"
androidxComposeMaterial3AdaptiveNavigationSuite = "1.3.0-beta01"
androidxComposeBom = "2024.09.00"
androidxComposeRuntimeTracing = "1.0.0-beta01"
androidxCore = "1.12.0"
androidxCoreSplashscreen = "1.0.1"
@ -20,7 +17,7 @@ androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.8.3"
androidxMacroBenchmark = "1.2.4"
androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.8.0-alpha06"
androidxNavigation = "2.8.0"
androidxProfileinstaller = "1.3.1"
androidxTestCore = "1.5.0"
androidxTestExt = "1.1.5"
@ -28,7 +25,7 @@ androidxTestRules = "1.5.0"
androidxTestRunner = "1.5.2"
androidxTracing = "1.3.0-alpha02"
androidxUiAutomator = "2.3.0"
androidxWindowManager = "1.3.0-alpha03"
androidxWindowManager = "1.3.0"
androidxWork = "2.9.0"
coil = "2.6.0"
dependencyGuard = "0.5.0"
@ -42,11 +39,11 @@ hilt = "2.51.1"
hiltExt = "1.1.0"
jacoco = "0.8.7"
junit4 = "4.13.2"
kotlin = "2.0.0"
kotlin = "2.0.20"
kotlinxCoroutines = "1.8.0"
kotlinxDatetime = "0.5.0"
kotlinxSerializationJson = "1.6.3"
ksp = "2.0.0-1.0.21"
ksp = "2.0.20-1.0.24"
moduleGraph = "2.5.0"
okhttp = "4.12.0"
protobuf = "4.26.1"
@ -71,18 +68,18 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidxMacroBenchmark" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "androidxComposeAlpha" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-navigationSuite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "androidxComposeMaterial3AdaptiveNavigationSuite" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-material3-navigationSuite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" }
androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" }
androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" }
androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" }
androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidxComposeAlpha" }
androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
@ -128,6 +125,7 @@ hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.r
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
javax-inject = { module = "javax.inject:javax.inject", version = "1" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" }

Binary file not shown.

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

7
gradlew vendored

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum

2
gradlew.bat vendored

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################

Loading…
Cancel
Save