diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 5f501b6c0..001140a87 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v4 - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 + uses: gradle/wrapper-validation-action@v2 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties @@ -37,7 +37,7 @@ jobs: java-version: 17 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 - name: Check build-logic run: ./gradlew check -p build-logic @@ -178,7 +178,7 @@ jobs: java-version: 17 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 - name: Build projects before running emulator run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index f738ae105..7de3cb11e 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4 - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 + uses: gradle/wrapper-validation-action@v2 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties diff --git a/README.md b/README.md index 6f13f5de2..be1270b16 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ tests against _all_ build variants which is both unecessary and will result in f A screenshot test takes a screenshot of a screen or a UI component within the app, and compares it with a previously recorded screenshot which is known to be rendered correctly. -For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemoDebug/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt) +For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt) to verify that the navigation is displayed correctly on different screen sizes -([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemoDebug/screenshots)). +([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemo/screenshots)). Now In Android uses [Roborazzi](https://github.com/takahirom/roborazzi) to run screenshot tests of certain screens and UI components. When working with screenshot tests the following gradle tasks are useful: diff --git a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt index 2c921f091..ca4bf99a3 100644 --- a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt +++ b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt @@ -12,41 +12,41 @@ androidx.browser:browser:1.6.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.6.1 -androidx.compose.animation:animation-core-android:1.6.1 -androidx.compose.animation:animation-core:1.6.1 -androidx.compose.animation:animation:1.6.1 -androidx.compose.foundation:foundation-android:1.6.1 -androidx.compose.foundation:foundation-layout-android:1.6.1 -androidx.compose.foundation:foundation-layout:1.6.1 -androidx.compose.foundation:foundation:1.6.1 +androidx.compose.animation:animation-android:1.6.2 +androidx.compose.animation:animation-core-android:1.6.2 +androidx.compose.animation:animation-core:1.6.2 +androidx.compose.animation:animation:1.6.2 +androidx.compose.foundation:foundation-android:1.6.2 +androidx.compose.foundation:foundation-layout-android:1.6.2 +androidx.compose.foundation:foundation-layout:1.6.2 +androidx.compose.foundation:foundation:1.6.2 androidx.compose.material3:material3-android:1.2.0 androidx.compose.material3:material3:1.2.0 -androidx.compose.material:material-icons-core-android:1.6.1 -androidx.compose.material:material-icons-core:1.6.1 -androidx.compose.material:material-icons-extended-android:1.6.1 -androidx.compose.material:material-icons-extended:1.6.1 -androidx.compose.material:material-ripple-android:1.6.1 -androidx.compose.material:material-ripple:1.6.1 -androidx.compose.runtime:runtime-android:1.6.1 -androidx.compose.runtime:runtime-saveable-android:1.6.1 -androidx.compose.runtime:runtime-saveable:1.6.1 -androidx.compose.runtime:runtime:1.6.1 -androidx.compose.ui:ui-android:1.6.1 -androidx.compose.ui:ui-geometry-android:1.6.1 -androidx.compose.ui:ui-geometry:1.6.1 -androidx.compose.ui:ui-graphics-android:1.6.1 -androidx.compose.ui:ui-graphics:1.6.1 -androidx.compose.ui:ui-text-android:1.6.1 -androidx.compose.ui:ui-text:1.6.1 -androidx.compose.ui:ui-tooling-preview-android:1.6.1 -androidx.compose.ui:ui-tooling-preview:1.6.1 -androidx.compose.ui:ui-unit-android:1.6.1 -androidx.compose.ui:ui-unit:1.6.1 -androidx.compose.ui:ui-util-android:1.6.1 -androidx.compose.ui:ui-util:1.6.1 -androidx.compose.ui:ui:1.6.1 -androidx.compose:compose-bom:2024.02.00 +androidx.compose.material:material-icons-core-android:1.6.2 +androidx.compose.material:material-icons-core:1.6.2 +androidx.compose.material:material-icons-extended-android:1.6.2 +androidx.compose.material:material-icons-extended:1.6.2 +androidx.compose.material:material-ripple-android:1.6.2 +androidx.compose.material:material-ripple:1.6.2 +androidx.compose.runtime:runtime-android:1.6.2 +androidx.compose.runtime:runtime-saveable-android:1.6.2 +androidx.compose.runtime:runtime-saveable:1.6.2 +androidx.compose.runtime:runtime:1.6.2 +androidx.compose.ui:ui-android:1.6.2 +androidx.compose.ui:ui-geometry-android:1.6.2 +androidx.compose.ui:ui-geometry:1.6.2 +androidx.compose.ui:ui-graphics-android:1.6.2 +androidx.compose.ui:ui-graphics:1.6.2 +androidx.compose.ui:ui-text-android:1.6.2 +androidx.compose.ui:ui-text:1.6.2 +androidx.compose.ui:ui-tooling-preview-android:1.6.2 +androidx.compose.ui:ui-tooling-preview:1.6.2 +androidx.compose.ui:ui-unit-android:1.6.2 +androidx.compose.ui:ui-unit:1.6.2 +androidx.compose.ui:ui-util-android:1.6.2 +androidx.compose.ui:ui-util:1.6.2 +androidx.compose.ui:ui:1.6.2 +androidx.compose:compose-bom:2024.02.01 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 @@ -80,10 +80,10 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.viewpager:viewpager:1.0.0 com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.code.findbugs:jsr305:3.0.2 -com.google.dagger:dagger-lint-aar:2.50 -com.google.dagger:dagger:2.50 -com.google.dagger:hilt-android:2.50 -com.google.dagger:hilt-core:2.50 +com.google.dagger:dagger-lint-aar:2.51 +com.google.dagger:dagger:2.51 +com.google.dagger:hilt-android:2.51 +com.google.dagger:hilt-core:2.51 com.google.guava:listenablefuture:1.0 com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.6.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d674f4bec..12ac3ded3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,7 @@ android { debug { applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix } - val release = getByName("release") { + release { isMinifyEnabled = true applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") @@ -89,17 +89,21 @@ dependencies { implementation(projects.sync.work) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.windowSizeClass) + implementation(libs.androidx.compose.runtime.tracing) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) - implementation(libs.androidx.tracing.ktx) + implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation(libs.androidx.compose.runtime.tracing) - implementation(libs.androidx.compose.material3.windowSizeClass) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.profileinstaller) + implementation(libs.androidx.tracing.ktx) implementation(libs.kotlinx.coroutines.guava) implementation(libs.coil.kt) + ksp(libs.hilt.compiler) + debugImplementation(libs.androidx.compose.ui.testManifest) debugImplementation(projects.uiTestHiltManifest) @@ -113,10 +117,12 @@ dependencies { testDemoImplementation(libs.robolectric) testDemoImplementation(libs.roborazzi) + testDemoImplementation(projects.core.screenshotTesting) androidTestImplementation(projects.core.testing) androidTestImplementation(projects.core.dataTest) androidTestImplementation(projects.core.datastoreTest) + androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.navigation.testing) androidTestImplementation(libs.accompanist.testharness) androidTestImplementation(libs.hilt.android.testing) diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt index 610ed087b..b9da05522 100644 --- a/app/dependencies/prodReleaseRuntimeClasspath.txt +++ b/app/dependencies/prodReleaseRuntimeClasspath.txt @@ -13,44 +13,46 @@ androidx.browser:browser:1.6.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.6.1 -androidx.compose.animation:animation-core-android:1.6.1 -androidx.compose.animation:animation-core:1.6.1 -androidx.compose.animation:animation:1.6.1 -androidx.compose.foundation:foundation-android:1.6.1 -androidx.compose.foundation:foundation-layout-android:1.6.1 -androidx.compose.foundation:foundation-layout:1.6.1 -androidx.compose.foundation:foundation:1.6.1 +androidx.compose.animation:animation-android:1.6.2 +androidx.compose.animation:animation-core-android:1.6.2 +androidx.compose.animation:animation-core:1.6.2 +androidx.compose.animation:animation:1.6.2 +androidx.compose.foundation:foundation-android:1.6.2 +androidx.compose.foundation:foundation-layout-android:1.6.2 +androidx.compose.foundation:foundation-layout:1.6.2 +androidx.compose.foundation:foundation:1.6.2 +androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06 +androidx.compose.material3:material3-adaptive:1.0.0-alpha06 androidx.compose.material3:material3-android:1.2.0 androidx.compose.material3:material3-window-size-class-android:1.2.0 androidx.compose.material3:material3-window-size-class:1.2.0 androidx.compose.material3:material3:1.2.0 -androidx.compose.material:material-icons-core-android:1.6.1 -androidx.compose.material:material-icons-core:1.6.1 -androidx.compose.material:material-icons-extended-android:1.6.1 -androidx.compose.material:material-icons-extended:1.6.1 -androidx.compose.material:material-ripple-android:1.6.1 -androidx.compose.material:material-ripple:1.6.1 -androidx.compose.runtime:runtime-android:1.6.1 -androidx.compose.runtime:runtime-saveable-android:1.6.1 -androidx.compose.runtime:runtime-saveable:1.6.1 +androidx.compose.material:material-icons-core-android:1.6.2 +androidx.compose.material:material-icons-core:1.6.2 +androidx.compose.material:material-icons-extended-android:1.6.2 +androidx.compose.material:material-icons-extended:1.6.2 +androidx.compose.material:material-ripple-android:1.6.2 +androidx.compose.material:material-ripple:1.6.2 +androidx.compose.runtime:runtime-android:1.6.2 +androidx.compose.runtime:runtime-saveable-android:1.6.2 +androidx.compose.runtime:runtime-saveable:1.6.2 androidx.compose.runtime:runtime-tracing:1.0.0-beta01 -androidx.compose.runtime:runtime:1.6.1 -androidx.compose.ui:ui-android:1.6.1 -androidx.compose.ui:ui-geometry-android:1.6.1 -androidx.compose.ui:ui-geometry:1.6.1 -androidx.compose.ui:ui-graphics-android:1.6.1 -androidx.compose.ui:ui-graphics:1.6.1 -androidx.compose.ui:ui-text-android:1.6.1 -androidx.compose.ui:ui-text:1.6.1 -androidx.compose.ui:ui-tooling-preview-android:1.6.1 -androidx.compose.ui:ui-tooling-preview:1.6.1 -androidx.compose.ui:ui-unit-android:1.6.1 -androidx.compose.ui:ui-unit:1.6.1 -androidx.compose.ui:ui-util-android:1.6.1 -androidx.compose.ui:ui-util:1.6.1 -androidx.compose.ui:ui:1.6.1 -androidx.compose:compose-bom:2024.02.00 +androidx.compose.runtime:runtime:1.6.2 +androidx.compose.ui:ui-android:1.6.2 +androidx.compose.ui:ui-geometry-android:1.6.2 +androidx.compose.ui:ui-geometry:1.6.2 +androidx.compose.ui:ui-graphics-android:1.6.2 +androidx.compose.ui:ui-graphics:1.6.2 +androidx.compose.ui:ui-text-android:1.6.2 +androidx.compose.ui:ui-text:1.6.2 +androidx.compose.ui:ui-tooling-preview-android:1.6.2 +androidx.compose.ui:ui-tooling-preview:1.6.2 +androidx.compose.ui:ui-unit-android:1.6.2 +androidx.compose.ui:ui-unit:1.6.2 +androidx.compose.ui:ui-util-android:1.6.2 +androidx.compose.ui:ui-util:1.6.2 +androidx.compose.ui:ui:1.6.2 +androidx.compose:compose-bom:2024.02.01 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core-splashscreen:1.0.1 @@ -116,7 +118,8 @@ androidx.vectordrawable:vectordrawable-animated:1.1.0 androidx.vectordrawable:vectordrawable:1.1.0 androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.viewpager:viewpager:1.0.0 -androidx.window:window:1.0.0 +androidx.window.extensions.core:core:1.0.0 +androidx.window:window:1.2.0 androidx.work:work-runtime-ktx:2.9.0 androidx.work:work-runtime:2.9.0 com.caverock:androidsvg-aar:1.4 @@ -139,10 +142,10 @@ com.google.android.gms:play-services-oss-licenses:17.0.1 com.google.android.gms:play-services-stats:17.0.2 com.google.android.gms:play-services-tasks:18.0.2 com.google.code.findbugs:jsr305:3.0.2 -com.google.dagger:dagger-lint-aar:2.50 -com.google.dagger:dagger:2.50 -com.google.dagger:hilt-android:2.50 -com.google.dagger:hilt-core:2.50 +com.google.dagger:dagger-lint-aar:2.51 +com.google.dagger:dagger:2.51 +com.google.dagger:hilt-android:2.51 +com.google.dagger:hilt-core:2.51 com.google.errorprone:error_prone_annotations:2.11.0 com.google.firebase:firebase-abt:21.1.1 com.google.firebase:firebase-analytics-ktx:21.4.0 diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 13f11c3b9..c9cc64120 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -274,10 +274,10 @@ class NavigationTest { // Select the last topic val topic = runBlocking { - topicsRepository.getTopics().first().sortedBy(Topic::name).last().name + topicsRepository.getTopics().first().sortedBy(Topic::name).last() } - onNodeWithTag("interests:topics").performScrollToNode(hasText(topic)) - onNodeWithText(topic).performClick() + onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name)) + onNodeWithText(topic.name).performClick() // Switch tab onNodeWithText(forYou).performClick() @@ -285,8 +285,8 @@ class NavigationTest { // Come back to Interests onNodeWithText(interests).performClick() - // Verify we're not in the list of interests - onNodeWithTag("interests:topics").assertDoesNotExist() + // Verify the topic is still shown + onNodeWithTag("topic:${topic.id}").assertExists() } } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 6167b0b59..39bc03de7 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -22,12 +22,11 @@ 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.forYouScreen -import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph +import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen -import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic -import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.ui.NiaAppState +import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen /** * Top-level navigation graph. Navigation is organized as explained at @@ -49,24 +48,16 @@ fun NiaNavHost( startDestination = startDestination, modifier = modifier, ) { - forYouScreen(onTopicClick = navController::navigateToTopic) + forYouScreen(onTopicClick = navController::navigateToInterests) bookmarksScreen( - onTopicClick = navController::navigateToTopic, + onTopicClick = navController::navigateToInterests, onShowSnackbar = onShowSnackbar, ) searchScreen( onBackClick = navController::popBackStack, onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, - onTopicClick = navController::navigateToTopic, - ) - interestsGraph( - onTopicClick = navController::navigateToTopic, - nestedGraphs = { - topicScreen( - onBackClick = navController::popBackStack, - onTopicClick = navController::navigateToTopic, - ) - }, + onTopicClick = navController::navigateToInterests, ) + interestsListDetailScreen() } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index d423adfbf..b653d8910 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -39,7 +39,7 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigat 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.navigateToInterestsGraph +import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS @@ -173,7 +173,7 @@ class NiaAppState( when (topLevelDestination) { FOR_YOU -> navController.navigateToForYou(topLevelNavOptions) BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions) - INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions) + INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions) } } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt new file mode 100644 index 000000000..d618c2d47 --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui.interests2pane + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +class Interests2PaneViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + val selectedTopicId: StateFlow = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null) + + fun onTopicClick(topicId: String?) { + savedStateHandle[TOPIC_ID_ARG] = topicId + } +} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt new file mode 100644 index 000000000..98327923f --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt @@ -0,0 +1,136 @@ +/* + * 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.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.PaneAdaptedValue +import androidx.compose.material3.adaptive.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG +import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE +import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic +import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen + +private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route" + +fun NavGraphBuilder.interestsListDetailScreen() { + composable( + route = INTERESTS_ROUTE, + arguments = listOf( + navArgument(TOPIC_ID_ARG) { + type = NavType.StringType + defaultValue = null + nullable = true + }, + ), + ) { + InterestsListDetailScreen() + } +} + +@Composable +internal fun InterestsListDetailScreen( + viewModel: Interests2PaneViewModel = hiltViewModel(), +) { + val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle() + InterestsListDetailScreen( + selectedTopicId = selectedTopicId, + onTopicClick = viewModel::onTopicClick, + ) +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +internal fun InterestsListDetailScreen( + selectedTopicId: String?, + onTopicClick: (String) -> Unit, +) { + val listDetailNavigator = rememberListDetailPaneScaffoldNavigator() + BackHandler(listDetailNavigator.canNavigateBack()) { + listDetailNavigator.navigateBack() + } + + val nestedNavController = rememberNavController() + + fun onTopicClickShowDetailPane(topicId: String) { + onTopicClick(topicId) + nestedNavController.navigateToTopic(topicId) { + popUpTo(DETAIL_PANE_NAVHOST_ROUTE) + } + listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + } + + ListDetailPaneScaffold( + scaffoldState = listDetailNavigator.scaffoldState, + listPane = { + InterestsRoute( + onTopicClick = ::onTopicClickShowDetailPane, + highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(), + ) + }, + detailPane = { + NavHost( + navController = nestedNavController, + startDestination = TOPIC_ROUTE, + route = DETAIL_PANE_NAVHOST_ROUTE, + ) { + topicScreen( + showBackButton = !listDetailNavigator.isListPaneVisible(), + onBackClick = listDetailNavigator::navigateBack, + onTopicClick = ::onTopicClickShowDetailPane, + ) + composable(route = TOPIC_ROUTE) { + Box { + Text("Placeholder") + } + } + } + }, + ) + LaunchedEffect(Unit) { + if (selectedTopicId != null) { + // Initial topic ID was provided when navigating to Interests, so show its details. + onTopicClickShowDetailPane(selectedTopicId) + } + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun ThreePaneScaffoldNavigator.isListPaneVisible(): Boolean = + scaffoldState.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun ThreePaneScaffoldNavigator.isDetailPaneVisible(): Boolean = + scaffoldState.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt similarity index 100% rename from app/src/main/java/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt rename to app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index cd8bcfeb0..f4d5bb0d0 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -39,6 +39,8 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 34 + @Suppress("UnstableApiUsage") + testOptions.animationsDisabled = true configureGradleManagedDevices(this) } extensions.configure { diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index b8699a05d..52c337521 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -34,6 +34,7 @@ class AndroidFeatureConventionPlugin : Plugin { testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" } + testOptions.animationsDisabled = true configureGradleManagedDevices(this) } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index d442d94ef..be5b41d07 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -40,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 34 + testOptions.animationsDisabled = true configureFlavors(this) configureGradleManagedDevices(this) // The resource prefix is derived from the module name, diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 72d37db1b..234313e1f 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -26,7 +26,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * Configure Compose-specific options */ internal fun Project.configureAndroidCompose( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { buildFeatures { @@ -41,6 +41,8 @@ internal fun Project.configureAndroidCompose( val bom = libs.findLibrary("androidx-compose-bom").get() add("implementation", platform(bom)) add("androidTestImplementation", platform(bom)) + add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get()) + add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get()) } testOptions { diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt index 6aa896444..f67e9093d 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt @@ -25,7 +25,7 @@ import org.gradle.kotlin.dsl.invoke * Configure project for Gradle managed devices */ internal fun configureGradleManagedDevices( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd") val pixel6 = DeviceConfig("Pixel 6", 31, "aosp") diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 903c84d8f..f9a6717c3 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -30,7 +30,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * Configure base Kotlin with Android options */ internal fun Project.configureKotlinAndroid( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { compileSdk = 34 diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt index 60d059ac0..633098604 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt @@ -20,7 +20,7 @@ enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: St } fun configureFlavors( - commonExtension: CommonExtension<*, *, *, *, *>, + commonExtension: CommonExtension<*, *, *, *, *, *>, flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {} ) { commonExtension.apply { diff --git a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index 5bd2b8a43..46158479c 100644 --- a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -22,11 +22,11 @@ import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRep import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeNewsRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeSearchContentsRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeTopicsRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import dagger.Binds @@ -39,7 +39,7 @@ import dagger.hilt.testing.TestInstallIn components = [SingletonComponent::class], replaces = [DataModule::class], ) -interface TestDataModule { +internal interface TestDataModule { @Binds fun bindsTopicRepository( fakeTopicsRepository: FakeTopicsRepository, diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt similarity index 96% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt index 39ad05d1e..5ceff4dd0 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.model.asEntity @@ -39,7 +39,7 @@ import javax.inject.Inject * This allows us to run the app with fake data, without needing an internet connection or working * backend. */ -class FakeNewsRepository @Inject constructor( +internal class FakeNewsRepository @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val datasource: FakeNiaNetworkDataSource, ) : NewsRepository { diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeRecentSearchRepository.kt similarity index 88% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeRecentSearchRepository.kt index 025b51f68..b8d949efe 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeRecentSearchRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository @@ -25,7 +25,7 @@ import javax.inject.Inject /** * Fake implementation of the [RecentSearchRepository] */ -class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { +internal class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit override fun getRecentSearchQueries(limit: Int): Flow> = diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeSearchContentsRepository.kt similarity index 87% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeSearchContentsRepository.kt index 65cced452..1feeb6dcc 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeSearchContentsRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.model.data.SearchResult @@ -25,7 +25,7 @@ import javax.inject.Inject /** * Fake implementation of the [SearchContentsRepository] */ -class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { +internal class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { override suspend fun populateFtsData() = Unit override fun searchContents(searchQuery: String): Flow = flowOf() diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeTopicsRepository.kt similarity index 94% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeTopicsRepository.kt index 0eefc8451..f8ebca29e 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeTopicsRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository @@ -36,7 +36,7 @@ import javax.inject.Inject * This allows us to run the app with fake data, without needing an internet connection or working * backend. */ -class FakeTopicsRepository @Inject constructor( +internal class FakeTopicsRepository @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val datasource: FakeNiaNetworkDataSource, ) : TopicsRepository { diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeUserDataRepository.kt similarity index 95% rename from core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt rename to core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeUserDataRepository.kt index a9da29b56..cdd23429f 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeUserDataRepository.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.data.repository.fake +package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource @@ -30,7 +30,7 @@ import javax.inject.Inject * This allows us to run the app with fake data, without needing an internet connection or working * backend. */ -class FakeUserDataRepository @Inject constructor( +internal class FakeUserDataRepository @Inject constructor( private val niaPreferencesDataSource: NiaPreferencesDataSource, ) : UserDataRepository { diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index dc4b78e01..a3e373918 100644 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -92,21 +92,6 @@ class TestNewsResourceDao : NewsResourceDao { result.map { it.entity.id } } - override suspend fun insertOrIgnoreNewsResources( - entities: List, - ): List { - entitiesStateFlow.update { oldValues -> - // Old values come first so new values don't overwrite them - (oldValues + entities) - .distinctBy(NewsResourceEntity::id) - .sortedWith( - compareBy(NewsResourceEntity::publishDate).reversed(), - ) - } - // Assume no conflicts on insert - return entities.map { it.id.toLong() } - } - override suspend fun upsertNewsResources(newsResourceEntities: List) { entitiesStateFlow.update { oldValues -> // New values come first so they overwrite old values diff --git a/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt b/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt index 55e817618..535ab61a7 100644 --- a/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt +++ b/core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt @@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant +import org.junit.After import org.junit.Before import org.junit.Test import kotlin.test.assertEquals @@ -48,6 +49,9 @@ class NewsResourceDaoTest { topicDao = db.topicDao() } + @After + fun closeDb() = db.close() + @Test fun newsResourceDao_fetches_items_by_descending_publish_date() = runTest { val newsResourceEntities = listOf( diff --git a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index 0ad1e4f7d..929b88ce6 100644 --- a/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -96,12 +96,6 @@ interface NewsResourceDao { filterNewsIds: Set = emptySet(), ): Flow> - /** - * Inserts [entities] into the db if they don't exist, and ignores those that do - */ - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertOrIgnoreNewsResources(entities: List): List - /** * Inserts or updates [newsResourceEntities] in the db under the specified primary keys */ diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index d68117d06..548e635bb 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -35,11 +35,8 @@ dependencies { api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material3) api(libs.androidx.compose.runtime) - api(libs.androidx.compose.ui.tooling.preview) api(libs.androidx.compose.ui.util) - debugApi(libs.androidx.compose.ui.tooling) - implementation(libs.coil.kt.compose) testImplementation(libs.androidx.compose.ui.test) @@ -47,6 +44,7 @@ dependencies { testImplementation(libs.hilt.android.testing) testImplementation(libs.robolectric) testImplementation(libs.roborazzi) + testImplementation(projects.core.screenshotTesting) testImplementation(projects.core.testing) androidTestImplementation(libs.androidx.compose.ui.test) diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt index 9f8f71f14..59f4f48a2 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt @@ -53,12 +53,12 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme fun RowScope.NiaNavigationBarItem( selected: Boolean, onClick: () -> Unit, - icon: @Composable () -> Unit, modifier: Modifier = Modifier, - selectedIcon: @Composable () -> Unit = icon, enabled: Boolean = true, - label: @Composable (() -> Unit)? = null, alwaysShowLabel: Boolean = true, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + label: @Composable (() -> Unit)? = null, ) { NavigationBarItem( selected = selected, @@ -117,12 +117,12 @@ fun NiaNavigationBar( fun NiaNavigationRailItem( selected: Boolean, onClick: () -> Unit, - icon: @Composable () -> Unit, modifier: Modifier = Modifier, - selectedIcon: @Composable () -> Unit = icon, enabled: Boolean = true, - label: @Composable (() -> Unit)? = null, alwaysShowLabel: Boolean = true, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + label: @Composable (() -> Unit)? = null, ) { NavigationRailItem( selected = selected, diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt index c8102073a..1086e280b 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt @@ -75,10 +75,10 @@ private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L */ @Composable fun ScrollableState.DraggableScrollbar( - modifier: Modifier = Modifier, state: ScrollbarState, orientation: Orientation, onThumbMoved: (Float) -> Unit, + modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } Scrollbar( @@ -105,9 +105,9 @@ fun ScrollableState.DraggableScrollbar( */ @Composable fun ScrollableState.DecorativeScrollbar( - modifier: Modifier = Modifier, state: ScrollbarState, orientation: Orientation, + modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } Scrollbar( diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt index 8c85e5be5..002f36b31 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt @@ -195,13 +195,13 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { */ @Composable fun Scrollbar( - modifier: Modifier = Modifier, orientation: Orientation, state: ScrollbarState, - minThumbSize: Dp = 40.dp, + modifier: Modifier = Modifier, interactionSource: MutableInteractionSource? = null, - thumb: @Composable () -> Unit, + minThumbSize: Dp = 40.dp, onThumbMoved: ((Float) -> Unit)? = null, + thumb: @Composable () -> Unit, ) { // Using Offset.Unspecified and Float.NaN instead of null // to prevent unnecessary boxing of primitives diff --git a/core/screenshot-testing/.gitignore b/core/screenshot-testing/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/screenshot-testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/screenshot-testing/build.gradle.kts b/core/screenshot-testing/build.gradle.kts new file mode 100644 index 000000000..4e9a931b0 --- /dev/null +++ b/core/screenshot-testing/build.gradle.kts @@ -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. + */ +plugins { + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.hilt) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.core.screenshottesting" +} + +dependencies { + api(libs.roborazzi) + implementation(libs.accompanist.testharness) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui.test) + implementation(libs.robolectric) + implementation(projects.core.common) + implementation(projects.core.designsystem) +} diff --git a/core/screenshot-testing/src/main/AndroidManifest.xml b/core/screenshot-testing/src/main/AndroidManifest.xml new file mode 100644 index 000000000..51d0cfc2e --- /dev/null +++ b/core/screenshot-testing/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt b/core/screenshot-testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt similarity index 100% rename from core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt rename to core/screenshot-testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 275555d80..02729ceff 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -26,7 +26,6 @@ android { dependencies { api(kotlin("test")) api(libs.androidx.compose.ui.test) - api(libs.roborazzi) api(projects.core.analytics) api(projects.core.data) api(projects.core.model) @@ -34,13 +33,10 @@ dependencies { debugApi(libs.androidx.compose.ui.testManifest) - implementation(libs.accompanist.testharness) - implementation(libs.androidx.activity.compose) implementation(libs.androidx.test.rules) implementation(libs.hilt.android.testing) implementation(libs.kotlinx.coroutines.test) implementation(libs.kotlinx.datetime) - implementation(libs.robolectric.shadows) implementation(projects.core.common) implementation(projects.core.designsystem) } diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index da0a7ec5b..fd41d9a13 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation(libs.hilt.android.testing) testImplementation(libs.robolectric) testImplementation(projects.core.testing) + testImplementation(projects.core.screenshotTesting) testDemoImplementation(libs.roborazzi) androidTestImplementation(projects.core.testing) diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts index f0bca9729..ee6aaf122 100644 --- a/feature/interests/build.gradle.kts +++ b/feature/interests/build.gradle.kts @@ -30,4 +30,4 @@ dependencies { testImplementation(projects.core.testing) androidTestImplementation(projects.core.testing) -} \ No newline at end of file +} diff --git a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt b/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt index 4f9cbcc04..1584662b8 100644 --- a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt +++ b/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt @@ -74,7 +74,10 @@ class InterestsScreenTest { fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() { composeTestRule.setContent { InterestsScreen( - uiState = InterestsUiState.Interests(topics = followableTopicTestData), + uiState = InterestsUiState.Interests( + topics = followableTopicTestData, + selectedTopicId = null, + ), ) } diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt index b055a3a14..6ac0340ee 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -49,6 +50,7 @@ fun InterestsItem( modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, description: String = "", + isSelected: Boolean = false, ) { ListItem( leadingContent = { @@ -83,10 +85,16 @@ fun InterestsItem( ) }, colors = ListItemDefaults.colors( - containerColor = Color.Transparent, + containerColor = if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + }, ), modifier = modifier - .semantics(mergeDescendants = true) { /* no-op */ } + .semantics(mergeDescendants = true) { + selected = isSelected + } .clickable(enabled = true, onClick = onClick), ) } @@ -179,3 +187,21 @@ private fun InterestsCardWithEmptyDescriptionPreview() { } } } + +@Preview +@Composable +private fun InterestsCardSelectedPreview() { + NiaTheme { + Surface { + InterestsItem( + name = "Compose", + description = "", + following = true, + topicImageUrl = "", + onClick = { }, + onFollowButtonClick = { }, + isSelected = true, + ) + } + } +} diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index 5944b8631..468550878 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -35,9 +35,10 @@ import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParame import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent @Composable -internal fun InterestsRoute( +fun InterestsRoute( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + highlightSelectedTopic: Boolean = false, viewModel: InterestsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -45,7 +46,11 @@ internal fun InterestsRoute( InterestsScreen( uiState = uiState, followTopic = viewModel::followTopic, - onTopicClick = onTopicClick, + onTopicClick = { + viewModel.onTopicClick(it) + onTopicClick(it) + }, + highlightSelectedTopic = highlightSelectedTopic, modifier = modifier, ) } @@ -56,6 +61,7 @@ internal fun InterestsScreen( followTopic: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + highlightSelectedTopic: Boolean = false, ) { Column( modifier = modifier, @@ -67,13 +73,17 @@ internal fun InterestsScreen( modifier = modifier, contentDesc = stringResource(id = R.string.feature_interests_loading), ) + is InterestsUiState.Interests -> TopicsTabContent( topics = uiState.topics, onTopicClick = onTopicClick, onFollowButtonClick = followTopic, + selectedTopicId = uiState.selectedTopicId, + highlightSelectedTopic = highlightSelectedTopic, modifier = modifier, ) + is InterestsUiState.Empty -> InterestsEmptyScreen() } } @@ -95,6 +105,7 @@ fun InterestsScreenPopulated( NiaBackground { InterestsScreen( uiState = InterestsUiState.Interests( + selectedTopicId = null, topics = followableTopics, ), followTopic = { _, _ -> }, diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt index 6d905a6d5..b369ac5ab 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt @@ -16,46 +16,57 @@ package com.google.samples.apps.nowinandroid.feature.interests +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.TopicSortField import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class InterestsViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, val userDataRepository: UserDataRepository, getFollowableTopics: GetFollowableTopicsUseCase, ) : ViewModel() { - val uiState: StateFlow = - getFollowableTopics(sortBy = TopicSortField.NAME).map( - InterestsUiState::Interests, - ).stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = InterestsUiState.Loading, - ) + val selectedTopicId: StateFlow = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null) + + val uiState: StateFlow = combine( + selectedTopicId, + getFollowableTopics(sortBy = TopicSortField.NAME), + InterestsUiState::Interests, + ).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InterestsUiState.Loading, + ) fun followTopic(followedTopicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.setTopicIdFollowed(followedTopicId, followed) } } + + fun onTopicClick(topicId: String?) { + savedStateHandle[TOPIC_ID_ARG] = topicId + } } sealed interface InterestsUiState { data object Loading : InterestsUiState data class Interests( + val selectedTopicId: String?, val topics: List, ) : InterestsUiState diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index d865f5e1a..4a48645c5 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -47,6 +47,8 @@ fun TopicsTabContent( onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, withBottomSpacer: Boolean = true, + selectedTopicId: String? = null, + highlightSelectedTopic: Boolean = false, ) { Box( modifier = modifier @@ -63,6 +65,7 @@ fun TopicsTabContent( topics.forEach { followableTopic -> val topicId = followableTopic.topic.id item(key = topicId) { + val isSelected = highlightSelectedTopic && topicId == selectedTopicId InterestsItem( name = followableTopic.topic.name, following = followableTopic.isFollowed, @@ -70,6 +73,7 @@ fun TopicsTabContent( topicImageUrl = followableTopic.topic.imageUrl, onClick = { onTopicClick(topicId) }, onFollowButtonClick = { onFollowButtonClick(topicId, it) }, + isSelected = isSelected, ) } } diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt index 2ad7c560b..8a0f2d130 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt @@ -19,26 +19,37 @@ package com.google.samples.apps.nowinandroid.feature.interests.navigation import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable -import androidx.navigation.navigation +import androidx.navigation.navArgument import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute -private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph" -const val INTERESTS_ROUTE = "interests_route" +const val TOPIC_ID_ARG = "topicId" +const val INTERESTS_ROUTE_BASE = "interests_route" +const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}" -fun NavController.navigateToInterestsGraph(navOptions: NavOptions) = navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions) +fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) { + val route = if (topicId != null) { + "${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId" + } else { + INTERESTS_ROUTE_BASE + } + navigate(route, navOptions) +} -fun NavGraphBuilder.interestsGraph( +fun NavGraphBuilder.interestsScreen( onTopicClick: (String) -> Unit, - nestedGraphs: NavGraphBuilder.() -> Unit, ) { - navigation( - route = INTERESTS_GRAPH_ROUTE_PATTERN, - startDestination = INTERESTS_ROUTE, + composable( + route = INTERESTS_ROUTE, + arguments = listOf( + navArgument(TOPIC_ID_ARG) { + defaultValue = null + nullable = true + type = NavType.StringType + }, + ), ) { - composable(route = INTERESTS_ROUTE) { - InterestsRoute(onTopicClick) - } - nestedGraphs() + InterestsRoute(onTopicClick = onTopicClick) } } diff --git a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt index 0dcbecf35..72fadfd94 100644 --- a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt +++ b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.interests +import androidx.lifecycle.SavedStateHandle import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic @@ -24,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -54,6 +56,7 @@ class InterestsViewModelTest { @Before fun setup() { viewModel = InterestsViewModel( + savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), userDataRepository = userDataRepository, getFollowableTopics = getFollowableTopicsUseCase, ) @@ -94,7 +97,10 @@ class InterestsViewModelTest { ) assertEquals( - InterestsUiState.Interests(topics = testOutputTopics), + InterestsUiState.Interests( + topics = testOutputTopics, + selectedTopicId = testInputTopics[0].topic.id, + ), viewModel.uiState.value, ) @@ -124,7 +130,10 @@ class InterestsViewModelTest { ) assertEquals( - InterestsUiState.Interests(topics = testInputTopics), + InterestsUiState.Interests( + topics = testInputTopics, + selectedTopicId = testInputTopics[0].topic.id, + ), viewModel.uiState.value, ) diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index d05f02b22..ca159c80b 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -100,10 +100,10 @@ import com.google.samples.apps.nowinandroid.feature.search.R as searchR @Composable internal fun SearchRoute( - modifier: Modifier = Modifier, onBackClick: () -> Unit, onInterestsClick: () -> Unit, onTopicClick: (String) -> Unit, + modifier: Modifier = Modifier, bookmarksViewModel: BookmarksViewModel = hiltViewModel(), interestsViewModel: InterestsViewModel = hiltViewModel(), searchViewModel: SearchViewModel = hiltViewModel(), @@ -114,36 +114,36 @@ internal fun SearchRoute( val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() SearchScreen( modifier = modifier, - onBackClick = onBackClick, - onClearRecentSearches = searchViewModel::clearRecentSearches, - onFollowButtonClick = interestsViewModel::followTopic, - onInterestsClick = onInterestsClick, + searchQuery = searchQuery, + recentSearchesUiState = recentSearchQueriesUiState, + searchResultUiState = searchResultUiState, onSearchQueryChanged = searchViewModel::onSearchQueryChanged, onSearchTriggered = searchViewModel::onSearchTriggered, - onTopicClick = onTopicClick, + onClearRecentSearches = searchViewModel::clearRecentSearches, onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved, onNewsResourceViewed = { bookmarksViewModel.setNewsResourceViewed(it, true) }, - recentSearchesUiState = recentSearchQueriesUiState, - searchQuery = searchQuery, - searchResultUiState = searchResultUiState, + onFollowButtonClick = interestsViewModel::followTopic, + onBackClick = onBackClick, + onInterestsClick = onInterestsClick, + onTopicClick = onTopicClick, ) } @Composable internal fun SearchScreen( modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, + searchQuery: String = "", + recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, + searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, + onSearchQueryChanged: (String) -> Unit = {}, + onSearchTriggered: (String) -> Unit = {}, onClearRecentSearches: () -> Unit = {}, - onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, - onInterestsClick: () -> Unit = {}, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> }, onNewsResourceViewed: (String) -> Unit = {}, - onSearchQueryChanged: (String) -> Unit = {}, - onSearchTriggered: (String) -> Unit = {}, + onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, + onBackClick: () -> Unit = {}, + onInterestsClick: () -> Unit = {}, onTopicClick: (String) -> Unit = {}, - searchQuery: String = "", - recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, - searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, ) { TrackScreenViewEvent(screenName = "Search") Column(modifier = modifier) { @@ -177,8 +177,8 @@ internal fun SearchScreen( is SearchResultUiState.Success -> { if (searchResultUiState.isEmpty()) { EmptySearchResultBody( - onInterestsClick = onInterestsClick, searchQuery = searchQuery, + onInterestsClick = onInterestsClick, ) if (recentSearchesUiState is RecentSearchQueriesUiState.Success) { RecentSearchesBody( @@ -192,14 +192,14 @@ internal fun SearchScreen( } } else { SearchResultBody( + searchQuery = searchQuery, topics = searchResultUiState.topics, - onFollowButtonClick = onFollowButtonClick, - onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, - onNewsResourceViewed = onNewsResourceViewed, + newsResources = searchResultUiState.newsResources, onSearchTriggered = onSearchTriggered, onTopicClick = onTopicClick, - newsResources = searchResultUiState.newsResources, - searchQuery = searchQuery, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onFollowButtonClick = onFollowButtonClick, ) } } @@ -210,8 +210,8 @@ internal fun SearchScreen( @Composable fun EmptySearchResultBody( - onInterestsClick: () -> Unit, searchQuery: String, + onInterestsClick: () -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -286,14 +286,14 @@ private fun SearchNotReadyBody() { @Composable private fun SearchResultBody( + searchQuery: String, topics: List, newsResources: List, - onFollowButtonClick: (String, Boolean) -> Unit, - onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, - onNewsResourceViewed: (String) -> Unit, onSearchTriggered: (String) -> Unit, onTopicClick: (String) -> Unit, - searchQuery: String = "", + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, + onFollowButtonClick: (String, Boolean) -> Unit, ) { val state = rememberLazyStaggeredGridState() Box( @@ -392,9 +392,9 @@ private fun SearchResultBody( @Composable private fun RecentSearchesBody( + recentSearchQueries: List, onClearRecentSearches: () -> Unit, onRecentSearchClicked: (String) -> Unit, - recentSearchQueries: List, ) { Column { Row( @@ -444,11 +444,11 @@ private fun RecentSearchesBody( @Composable private fun SearchToolbar( - modifier: Modifier = Modifier, - onBackClick: () -> Unit, + searchQuery: String, onSearchQueryChanged: (String) -> Unit, - searchQuery: String = "", onSearchTriggered: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -473,8 +473,8 @@ private fun SearchToolbar( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun SearchTextField( - onSearchQueryChanged: (String) -> Unit, searchQuery: String, + onSearchQueryChanged: (String) -> Unit, onSearchTriggered: (String) -> Unit, ) { val focusRequester = remember { FocusRequester() } @@ -556,6 +556,7 @@ private fun SearchTextField( private fun SearchToolbarPreview() { NiaTheme { SearchToolbar( + searchQuery = "", onBackClick = {}, onSearchQueryChanged = {}, onSearchTriggered = {}, diff --git a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index b64e397ea..2b87baf9e 100644 --- a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -55,6 +55,7 @@ class TopicScreenTest { TopicScreen( topicUiState = TopicUiState.Loading, newsUiState = NewsUiState.Loading, + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, @@ -75,6 +76,7 @@ class TopicScreenTest { TopicScreen( topicUiState = TopicUiState.Success(testTopic), newsUiState = NewsUiState.Loading, + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, @@ -100,6 +102,7 @@ class TopicScreenTest { TopicScreen( topicUiState = TopicUiState.Loading, newsUiState = NewsUiState.Success(userNewsResourcesTestData), + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, @@ -123,6 +126,7 @@ class TopicScreenTest { newsUiState = NewsUiState.Success( userNewsResourcesTestData, ), + showBackButton = true, onBackClick = {}, onFollowClick = {}, onTopicClick = {}, diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index 3f3862c2a..4402cecd4 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight @@ -44,6 +45,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -70,6 +72,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.R.string @Composable internal fun TopicRoute( + showBackButton: Boolean, onBackClick: () -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, @@ -82,7 +85,8 @@ internal fun TopicRoute( TopicScreen( topicUiState = topicUiState, newsUiState = newsUiState, - modifier = modifier, + modifier = modifier.testTag("topic:${viewModel.topicId}"), + showBackButton = showBackButton, onBackClick = onBackClick, onFollowClick = viewModel::followTopicToggle, onBookmarkChanged = viewModel::bookmarkNews, @@ -96,6 +100,7 @@ internal fun TopicRoute( internal fun TopicScreen( topicUiState: TopicUiState, newsUiState: NewsUiState, + showBackButton: Boolean, onBackClick: () -> Unit, onFollowClick: (Boolean) -> Unit, onTopicClick: (String) -> Unit, @@ -127,6 +132,7 @@ internal fun TopicScreen( is TopicUiState.Success -> { item { TopicToolbar( + showBackButton = showBackButton, onBackClick = onBackClick, onFollowClick = onFollowClick, uiState = topicUiState.followableTopic, @@ -270,6 +276,7 @@ private fun TopicBodyPreview() { private fun TopicToolbar( uiState: FollowableTopic, modifier: Modifier = Modifier, + showBackButton: Boolean = true, onBackClick: () -> Unit = {}, onFollowClick: (Boolean) -> Unit = {}, ) { @@ -280,13 +287,18 @@ private fun TopicToolbar( .fillMaxWidth() .padding(bottom = 32.dp), ) { - IconButton(onClick = { onBackClick() }) { - Icon( - imageVector = NiaIcons.ArrowBack, - contentDescription = stringResource( - id = com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_back, - ), - ) + if (showBackButton) { + IconButton(onClick = { onBackClick() }) { + Icon( + imageVector = NiaIcons.ArrowBack, + contentDescription = stringResource( + id = com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_back, + ), + ) + } + } else { + // Keeps the NiaFilterChip aligned to the end of the Row. + Spacer(modifier = Modifier.width(1.dp)) } val selected = uiState.isFollowed NiaFilterChip( @@ -314,6 +326,7 @@ fun TopicScreenPopulated( TopicScreen( topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]), newsUiState = NewsUiState.Success(userNewsResources), + showBackButton = true, onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, @@ -332,6 +345,7 @@ fun TopicScreenLoading() { TopicScreen( topicUiState = TopicUiState.Loading, newsUiState = NewsUiState.Loading, + showBackButton = true, onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt index bba46c5ab..41804b634 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt @@ -20,6 +20,7 @@ import androidx.annotation.VisibleForTesting import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument @@ -32,20 +33,23 @@ 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)) } -fun NavController.navigateToTopic(topicId: String) { +fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING) - navigate("topic_route/$encodedId") { - launchSingleTop = true + val newRoute = "$TOPIC_ROUTE/$encodedId" + navigate(newRoute) { + navOptions() } } fun NavGraphBuilder.topicScreen( + showBackButton: Boolean, onBackClick: () -> Unit, onTopicClick: (String) -> Unit, ) { @@ -55,6 +59,10 @@ fun NavGraphBuilder.topicScreen( navArgument(TOPIC_ID_ARG) { type = NavType.StringType }, ), ) { - TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick) + TopicRoute( + showBackButton = showBackButton, + onBackClick = onBackClick, + onTopicClick = onTopicClick, + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85064eb6d..481494a48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,13 +2,14 @@ accompanist = "0.32.0" androidDesugarJdkLibs = "2.0.4" # AGP and tools should be updated together -androidGradlePlugin = "8.2.0" -androidTools = "31.2.0" +androidGradlePlugin = "8.3.0" +androidTools = "31.3.0" androidxActivity = "1.8.0" androidxAppCompat = "1.6.1" androidxBrowser = "1.6.0" -androidxComposeBom = "2024.02.00" +androidxComposeBom = "2024.02.01" androidxComposeCompiler = "1.5.8" +androidxComposeMaterial3Adaptive = "1.0.0-alpha06" androidxComposeRuntimeTracing = "1.0.0-beta01" androidxCore = "1.12.0" androidxCoreSplashscreen = "1.0.1" @@ -36,7 +37,7 @@ firebasePerfPlugin = "1.4.2" gmsPlugin = "4.4.0" googleOss = "17.0.1" googleOssPlugin = "0.10.6" -hilt = "2.50" +hilt = "2.51" hiltExt = "1.1.0" jacoco = "0.8.7" junit4 = "4.13.2" @@ -44,7 +45,7 @@ kotlin = "1.9.22" kotlinxCoroutines = "1.7.3" kotlinxDatetime = "0.5.0" kotlinxSerializationJson = "1.6.0" -ksp = "1.9.22-1.0.16" +ksp = "1.9.22-1.0.18" okhttp = "4.12.0" protobuf = "3.25.2" protobufPlugin = "0.9.4" @@ -54,7 +55,7 @@ robolectric = "4.11.1" roborazzi = "1.7.0" room = "2.6.1" secrets = "2.0.1" -truth = "1.1.5" +truth = "1.4.2" turbine = "1.0.0" [libraries] @@ -70,6 +71,7 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f 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-adaptive = { group = "androidx.compose.material3", name = "material3-adaptive", version.ref = "androidxComposeMaterial3Adaptive" } 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" } @@ -128,7 +130,6 @@ protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } -robolectric-shadows = { group = "org.robolectric", name = "shadows-framework", version.ref = "robolectric" } roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } diff --git a/settings.gradle.kts b/settings.gradle.kts index fa043c955..949dbfdd1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") include(":app-nia-catalog") include(":benchmarks") +include(":core:analytics") include(":core:common") include(":core:data") include(":core:data-test") @@ -47,10 +48,10 @@ include(":core:designsystem") include(":core:domain") include(":core:model") include(":core:network") -include(":core:ui") -include(":core:testing") -include(":core:analytics") include(":core:notifications") +include(":core:screenshot-testing") +include(":core:testing") +include(":core:ui") include(":feature:foryou") include(":feature:interests") diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index 7e61c7389..97e3eace2 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -40,6 +40,5 @@ dependencies { androidTestImplementation(libs.androidx.work.testing) androidTestImplementation(libs.hilt.android.testing) - androidTestImplementation(libs.kotlinx.coroutines.guava) androidTestImplementation(projects.core.testing) } diff --git a/tools/setup.sh b/tools/setup.sh index 1467bbad0..b0f204268 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -35,7 +35,7 @@ cp "${GIT_ROOT}/tools/pre-push" "${GIT_DIR}/hooks/pre-push" \ cat <<-EOF Checking the following settings helps avoid miscellaneous issues: * Settings -> Editor -> General -> Remove trailing spaces on: Modified lines - * Settings -> Editor -> General -> Ensure every file ends with a line break + * Settings -> Editor -> General -> Ensure every saved file ends with a line break * Settings -> Editor -> General -> Auto Import -> Optimize imports on the fly (for both Kotlin\ and Java) EOF