Merge branch 'android:main' into main-safe

pull/1238/head
Jaehwa Noh 2 years ago committed by GitHub
commit bd31b14226
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -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

@ -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:

@ -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

@ -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)

@ -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

@ -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()
}
}
}

@ -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()
}
}

@ -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)
}
}
}

@ -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<String?> = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null)
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId
}
}

@ -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<Nothing>()
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 <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
scaffoldState.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
scaffoldState.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded

@ -39,6 +39,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
@Suppress("UnstableApiUsage")
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}
extensions.configure<ApplicationAndroidComponentsExtension> {

@ -34,6 +34,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}

@ -40,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
testOptions.animationsDisabled = true
configureFlavors(this)
configureGradleManagedDevices(this)
// The resource prefix is derived from the module name,

@ -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 {

@ -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")

@ -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

@ -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 {

@ -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,

@ -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 {

@ -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<List<RecentSearchQuery>> =

@ -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<SearchResult> = flowOf()

@ -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 {

@ -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 {

@ -92,21 +92,6 @@ class TestNewsResourceDao : NewsResourceDao {
result.map { it.entity.id }
}
override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>,
): List<Long> {
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<NewsResourceEntity>) {
entitiesStateFlow.update { oldValues ->
// New values come first so they overwrite old values

@ -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(

@ -96,12 +96,6 @@ interface NewsResourceDao {
filterNewsIds: Set<String> = emptySet(),
): Flow<List<String>>
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long>
/**
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys
*/

@ -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)

@ -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,

@ -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(

@ -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

@ -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)
}

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest />

@ -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)
}

@ -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)

@ -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,
),
)
}

@ -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,
)
}
}
}

@ -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 = { _, _ -> },

@ -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<InterestsUiState> =
getFollowableTopics(sortBy = TopicSortField.NAME).map(
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null)
val uiState: StateFlow<InterestsUiState> = 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<FollowableTopic>,
) : InterestsUiState

@ -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,
)
}
}

@ -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)
}
}

@ -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,
)

@ -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<FollowableTopic>,
newsResources: List<UserNewsResource>,
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<String>,
onClearRecentSearches: () -> Unit,
onRecentSearchClicked: (String) -> Unit,
recentSearchQueries: List<String>,
) {
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 = {},

@ -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 = {},

@ -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 = { _, _ -> },

@ -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,
)
}
}

@ -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" }

@ -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")

@ -40,6 +40,5 @@ dependencies {
androidTestImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.kotlinx.coroutines.guava)
androidTestImplementation(projects.core.testing)
}

@ -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

Loading…
Cancel
Save