Refactor feature modules to navigation3

pull/1902/head
Clara Fok 2 months ago
parent a163dd7929
commit b96b3e9e40

@ -25,7 +25,7 @@ import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYou
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import kotlin.reflect.KClass import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR
@ -62,8 +62,8 @@ enum class TopLevelDestination(
BOOKMARKS( BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks, selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder, unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title, iconTextId = bookmarksR.string.feature_bookmarks_impl_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title, titleTextId = bookmarksR.string.feature_bookmarks_impl_title,
route = BookmarksRoute::class, route = BookmarksRoute::class,
), ),
INTERESTS( INTERESTS(

@ -16,19 +16,8 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
} }
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api" namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api"
} }
dependencies {
implementation(projects.core.data)
testImplementation(projects.core.testing)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}

@ -19,8 +19,6 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.BookmarksRoute
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable object BookmarksRoute @Serializable object BookmarksRoute
@ -32,7 +30,10 @@ fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
) { ) {
composable<BookmarksRoute> { // composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar) // BookmarksRoute(
} // onTopicClick,
// onShowSnackbar
// )
// }
} }

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

@ -23,4 +23,14 @@ android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.impl" namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.impl"
} }
dependencies { } dependencies {
implementation(projects.core.data)
implementation(projects.feature.bookmarks.api)
implementation(projects.core.navigation)
implementation(projects.feature.topic.api)
testImplementation(projects.core.testing)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.bookmarks.api package com.google.samples.apps.nowinandroid.feature.bookmarks.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -63,7 +63,7 @@ class BookmarksScreenTest {
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_bookmarks_loading), composeTestRule.activity.resources.getString(R.string.feature_bookmarks_api_loading),
) )
.assertExists() .assertExists()
} }
@ -160,13 +160,13 @@ class BookmarksScreenTest {
composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_empty_error), composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error),
) )
.assertExists() .assertExists()
composeTestRule composeTestRule
.onNodeWithText( .onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_empty_description), composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description),
) )
.assertExists() .assertExists()
} }

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.bookmarks.api package com.google.samples.apps.nowinandroid.feature.bookmarks.impl
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@ -112,8 +112,8 @@ internal fun BookmarksScreen(
undoBookmarkRemoval: () -> Unit = {}, undoBookmarkRemoval: () -> Unit = {},
clearUndoState: () -> Unit = {}, clearUndoState: () -> Unit = {},
) { ) {
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed) val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_impl_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_api_undo) val undoText = stringResource(id = R.string.feature_bookmarks_impl_undo)
LaunchedEffect(shouldDisplayUndoBookmark) { LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) {
@ -155,7 +155,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
.fillMaxWidth() .fillMaxWidth()
.wrapContentSize() .wrapContentSize()
.testTag("forYou:loading"), .testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.feature_bookmarks_api_loading), contentDesc = stringResource(id = R.string.feature_bookmarks_impl_loading),
) )
} }
@ -228,7 +228,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
val iconTint = LocalTintTheme.current.iconTint val iconTint = LocalTintTheme.current.iconTint
Image( Image(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
painter = painterResource(id = R.drawable.feature_bookmarks_api_mg_empty_bookmarks), painter = painterResource(id = R.drawable.feature_bookmarks_impl_mg_empty_bookmarks),
colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null, colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null,
contentDescription = null, contentDescription = null,
) )
@ -236,7 +236,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
Text( Text(
text = stringResource(id = R.string.feature_bookmarks_api_empty_error), text = stringResource(id = R.string.feature_bookmarks_impl_empty_error),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -246,7 +246,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(id = R.string.feature_bookmarks_api_empty_description), text = stringResource(id = R.string.feature_bookmarks_impl_empty_description),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

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

@ -0,0 +1,62 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object BookmarksModule {
@Provides
@IntoSet
fun provideBookmarksEntryProviderBuilder(
backStack: NiaBackStack,
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
entry<BookmarksRoute> {
val snackbarHostState = LocalSnackbarHostState.current
BookmarksRoute(
onTopicClick = backStack::navigateToTopic,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
}
)
}
}
}
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("host state should be initialzied at runtime")
}

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

@ -15,10 +15,10 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<string name="feature_bookmarks_api_title">Saved</string> <string name="feature_bookmarks_impl_title">Saved</string>
<string name="feature_bookmarks_api_loading">Loading saved…</string> <string name="feature_bookmarks_impl_loading">Loading saved…</string>
<string name="feature_bookmarks_api_empty_error">No saved updates</string> <string name="feature_bookmarks_impl_empty_error">No saved updates</string>
<string name="feature_bookmarks_api_empty_description">Updates you save will be stored here\nto read later</string> <string name="feature_bookmarks_impl_empty_description">Updates you save will be stored here\nto read later</string>
<string name="feature_bookmarks_api_removed">Bookmark removed</string> <string name="feature_bookmarks_impl_removed">Bookmark removed</string>
<string name="feature_bookmarks_api_undo">UNDO</string> <string name="feature_bookmarks_impl_undo">UNDO</string>
</resources> </resources>

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

@ -16,9 +16,6 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.roborazzi)
} }
android { android {

@ -19,11 +19,6 @@ package com.google.samples.apps.nowinandroid.feature.foryou.api.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_URI_PATTERN
import com.google.samples.apps.nowinandroid.feature.foryou.api.ForYouScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable data object ForYouRoute // route to ForYou screen @Serializable data object ForYouRoute // route to ForYou screen
@ -43,23 +38,23 @@ fun NavGraphBuilder.forYouSection(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
topicDestination: NavGraphBuilder.() -> Unit, topicDestination: NavGraphBuilder.() -> Unit,
) { ) {
navigation<ForYouBaseRoute>(startDestination = ForYouRoute) { // navigation<ForYouBaseRoute>(startDestination = ForYouRoute) {
composable<ForYouRoute>( // composable<ForYouRoute>(
deepLinks = listOf( // deepLinks = listOf(
navDeepLink { // navDeepLink {
/** // /**
* This destination has a deep link that enables a specific news resource to be // * This destination has a deep link that enables a specific news resource to be
* opened from a notification (@see SystemTrayNotifier for more). The news resource // * opened from a notification (@see SystemTrayNotifier for more). The news resource
* ID is sent in the URI rather than being modelled in the route type because it's // * ID is sent in the URI rather than being modelled in the route type because it's
* transient data (stored in SavedStateHandle) that is cleared after the user has // * transient data (stored in SavedStateHandle) that is cleared after the user has
* opened the news resource. // * opened the news resource.
*/ // */
uriPattern = DEEP_LINK_URI_PATTERN // uriPattern = DEEP_LINK_URI_PATTERN
}, // },
), // ),
) { // ) {
ForYouScreen(onTopicClick) // com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen(onTopicClick)
} // }
topicDestination() // topicDestination()
} // }
} }

@ -21,5 +21,4 @@
<string name="feature_foryou_api_navigate_up">Navigate up</string> <string name="feature_foryou_api_navigate_up">Navigate up</string>
<string name="feature_foryou_api_onboarding_guidance_title">What are you interested in?</string> <string name="feature_foryou_api_onboarding_guidance_title">What are you interested in?</string>
<string name="feature_foryou_api_onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string> <string name="feature_foryou_api_onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string>
</resources> </resources>

@ -17,10 +17,27 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.roborazzi)
} }
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.foryou.impl" namespace = "com.google.samples.apps.nowinandroid.feature.foryou.impl"
} }
dependencies { } dependencies {
implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain)
implementation(projects.core.notifications)
implementation(projects.core.navigation)
implementation(projects.feature.foryou.api)
implementation(projects.feature.topic.api)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
testImplementation(projects.core.testing)
testDemoImplementation(projects.core.screenshotTesting)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou.api package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -32,6 +32,7 @@ import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPer
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -200,7 +201,9 @@ class ForYouScreenTest {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = onboardingUiState =
OnboardingUiState.Shown(topics = followableTopicTestData), OnboardingUiState.Shown(
topics = followableTopicTestData
),
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null, deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.foryou.api package com.google.samples.apps.nowinandroid.feature.foryou.impl
import android.net.Uri import android.net.Uri
import android.os.Build.VERSION import android.os.Build.VERSION
@ -103,6 +103,7 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
@Composable @Composable
internal fun ForYouScreen( internal fun ForYouScreen(

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

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

@ -0,0 +1,46 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object ForYouModule {
@Provides
@IntoSet
fun provideForYouEntryProviderBuilder(
backStack: NiaBackStack,
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
entry<ForYouBaseRoute> {
ForYouScreen(
onTopicClick = backStack::navigateToTopic
)
}
}
}

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

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

@ -16,20 +16,12 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
} }
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.interests.api" namespace = "com.google.samples.apps.nowinandroid.feature.interests.api"
} }
dependencies { dependencies {
implementation(projects.core.data) implementation(projects.core.navigation)
implementation(projects.core.domain)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
} }

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.feature.interests.api.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable data class InterestsRoute( @Serializable data class InterestsRoute(
@ -31,3 +32,9 @@ fun NavController.navigateToInterests(
) { ) {
navigate(route = InterestsRoute(initialTopicId), navOptions) navigate(route = InterestsRoute(initialTopicId), navOptions)
} }
fun NiaBackStack.navigateToInterests(
initialTopicId: String? = null,
) {
navigate(InterestsRoute(initialTopicId))
}

@ -22,3 +22,24 @@ plugins {
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.interests.impl" namespace = "com.google.samples.apps.nowinandroid.feature.interests.impl"
} }
dependencies {
implementation(projects.core.data)
implementation(projects.core.domain)
implementation(projects.feature.topic.api)
implementation(projects.feature.interests.api)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.androidx.test.espresso.core)
testImplementation(libs.hilt.android.testing)
testImplementation(projects.uiTestHiltManifest)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.feature.interests.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -25,8 +25,6 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.feature.interests.api.InterestsScreen
import com.google.samples.apps.nowinandroid.feature.interests.api.InterestsUiState
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test

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

@ -0,0 +1,41 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.interests.impl
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation.compose.composable
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object InterestsModule {
@Provides
@IntoSet
fun provideInterestsEntryProviderBuilder(): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
entry<InterestsRoute> { key ->
InterestsListDetailScreen()
}
}
}

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

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.interests.api package com.google.samples.apps.nowinandroid.feature.interests.impl
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -33,6 +33,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.feature.interests.api.R
@Composable @Composable
fun InterestsRoute( fun InterestsRoute(

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

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.interests.api package com.google.samples.apps.nowinandroid.feature.interests.impl
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box

@ -0,0 +1,203 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.interests.impl
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty
import kotlin.test.assertTrue
import com.google.samples.apps.nowinandroid.feature.topic.api.R as FeatureTopicR
private const val EXPANDED_WIDTH = "w1200dp-h840dp"
private const val COMPACT_WIDTH = "w412dp-h915dp"
@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
class InterestsListDetailScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
@Inject
lateinit var topicsRepository: TopicsRepository
/** Convenience function for getting all topics during tests, */
private fun getTopics(): List<Topic> = runBlocking {
topicsRepository.getTopics().first().sortedBy { it.name }
}
// The strings used for matching in these tests.
private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_api_select_an_interest)
private val listPaneTag = "interests:topics"
private val Topic.testTag
get() = "topic:${this.id}"
@Before
fun setup() {
hiltRule.inject()
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {
composeTestRule.apply {
setContent {
NiaTheme {
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
}
}
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_initialState_showsListPane() {
composeTestRule.apply {
setContent {
NiaTheme {
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
}
}
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
}
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_topicSelected_updatesDetailPane() {
composeTestRule.apply {
setContent {
NiaTheme {
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_topicSelected_showsTopicDetailPane() {
composeTestRule.apply {
setContent {
NiaTheme {
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
onNodeWithTag(listPaneTag).assertIsNotDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_backPressFromTopicDetail_leavesInterests() {
var unhandledBackPress = false
composeTestRule.apply {
setContent {
NiaTheme {
// Back press should not be handled by the two pane layout, and thus
// "fall through" to this BackHandler.
BackHandler {
unhandledBackPress = true
}
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
assertTrue(unhandledBackPress)
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_backPressFromTopicDetail_showsListPane() {
composeTestRule.apply {
setContent {
NiaTheme {
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed()
}
}
}
private fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.interests.api package com.google.samples.apps.nowinandroid.interests.impl
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.navigation.testing.invoke import androidx.navigation.testing.invoke

@ -16,8 +16,6 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
} }
android { android {
@ -27,6 +25,7 @@ android {
dependencies { dependencies {
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(projects.core.navigation)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)

@ -19,12 +19,19 @@ package com.google.samples.apps.nowinandroid.feature.search.api.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.feature.search.api.SearchRoute
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable data object SearchRoute @Serializable data object SearchRoute
@Serializable data class SearchRouteNav3(val onInterestsClick: () -> Unit)
fun NiaBackStack.navigateToSearch(
onInterestsClick: () -> Unit,
) {
navigate(SearchRouteNav3(onInterestsClick))
}
fun NavController.navigateToSearch(navOptions: NavOptions? = null) = fun NavController.navigateToSearch(navOptions: NavOptions? = null) =
navigate(SearchRoute, navOptions) navigate(SearchRoute, navOptions)
@ -33,13 +40,13 @@ fun NavGraphBuilder.searchScreen(
onInterestsClick: () -> Unit, onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
// TODO: Handle back stack for each top-level destination. At the moment each top-level // // TODO: Handle back stack for each top-level destination. At the moment each top-level
// destination may have own search screen's back stack. // // destination may have own search screen's back stack.
composable<SearchRoute> { // composable<SearchRoute> {
com.google.samples.apps.nowinandroid.feature.search.api.SearchRoute( // com.google.samples.apps.nowinandroid.feature.search.impl.SearchRoute(
onBackClick = onBackClick, // onBackClick = onBackClick,
onInterestsClick = onInterestsClick, // onInterestsClick = onInterestsClick,
onTopicClick = onTopicClick, // onTopicClick = onTopicClick,
) // )
} // }
} }

@ -23,3 +23,16 @@ plugins {
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.search.impl" namespace = "com.google.samples.apps.nowinandroid.feature.search.impl"
} }
dependencies {
implementation(projects.core.data)
implementation(projects.core.domain)
implementation(projects.core.navigation)
implementation(projects.feature.interests.api)
implementation(projects.feature.search.api)
testImplementation(projects.core.testing)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.search.api package com.google.samples.apps.nowinandroid.feature.search.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
@ -36,6 +36,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.R.string import com.google.samples.apps.nowinandroid.core.ui.R.string
import com.google.samples.apps.nowinandroid.feature.search.api.R
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -70,17 +71,17 @@ class SearchScreenTest {
@Before @Before
fun setup() { fun setup() {
composeTestRule.activity.apply { composeTestRule.activity.apply {
clearSearchContentDesc = getString(R.string.feature_search_clear_search_text_content_desc) clearSearchContentDesc = getString(R.string.feature_search_api_clear_search_text_content_desc)
clearRecentSearchesContentDesc = getString(R.string.feature_search_clear_recent_searches_content_desc) clearRecentSearchesContentDesc = getString(R.string.feature_search_api_clear_recent_searches_content_desc)
followButtonContentDesc = followButtonContentDesc =
getString(string.core_ui_interests_card_follow_button_content_desc) getString(string.core_ui_interests_card_follow_button_content_desc)
unfollowButtonContentDesc = unfollowButtonContentDesc =
getString(string.core_ui_interests_card_unfollow_button_content_desc) getString(string.core_ui_interests_card_unfollow_button_content_desc)
topicsString = getString(R.string.feature_search_topics) topicsString = getString(R.string.feature_search_api_topics)
updatesString = getString(R.string.feature_search_updates) updatesString = getString(R.string.feature_search_api_updates)
tryAnotherSearchString = getString(R.string.feature_search_try_another_search) + tryAnotherSearchString = getString(R.string.feature_search_api_try_another_search) +
" " + getString(R.string.feature_search_interests) + " " + getString(R.string.feature_search_to_browse_topics) " " + getString(R.string.feature_search_api_interests) + " " + getString(R.string.feature_search_api_to_browse_topics)
searchNotReadyString = getString(R.string.feature_search_not_ready) searchNotReadyString = getString(R.string.feature_search_api_not_ready)
} }
} }

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

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

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.search.api package com.google.samples.apps.nowinandroid.feature.search.impl
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
@ -93,6 +93,10 @@ import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import com.google.samples.apps.nowinandroid.core.ui.R.string import com.google.samples.apps.nowinandroid.core.ui.R.string
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.search.impl.RecentSearchQueriesUiState.Loading
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.EmptyQuery
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.LoadFailed
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.SearchNotReady
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR
@Composable @Composable
@ -127,7 +131,7 @@ internal fun SearchRoute(
internal fun SearchScreen( internal fun SearchScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchQuery: String = "", searchQuery: String = "",
recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, recentSearchesUiState: RecentSearchQueriesUiState = Loading,
searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, searchResultUiState: SearchResultUiState = SearchResultUiState.Loading,
onSearchQueryChanged: (String) -> Unit = {}, onSearchQueryChanged: (String) -> Unit = {},
onSearchTriggered: (String) -> Unit = {}, onSearchTriggered: (String) -> Unit = {},
@ -150,11 +154,11 @@ internal fun SearchScreen(
) )
when (searchResultUiState) { when (searchResultUiState) {
SearchResultUiState.Loading, SearchResultUiState.Loading,
SearchResultUiState.LoadFailed, LoadFailed,
-> Unit -> Unit
SearchResultUiState.SearchNotReady -> SearchNotReadyBody() SearchNotReady -> SearchNotReadyBody()
SearchResultUiState.EmptyQuery, EmptyQuery,
-> { -> {
if (recentSearchesUiState is RecentSearchQueriesUiState.Success) { if (recentSearchesUiState is RecentSearchQueriesUiState.Success) {
RecentSearchesBody( RecentSearchesBody(

@ -16,13 +16,13 @@
@file:Suppress("ktlint:standard:max-line-length") @file:Suppress("ktlint:standard:max-line-length")
package com.google.samples.apps.nowinandroid.feature.search.api package com.google.samples.apps.nowinandroid.feature.search.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources
import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics
import com.google.samples.apps.nowinandroid.feature.search.api.SearchResultUiState.Success import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Success
/** /**
* This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider)

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.search.api package com.google.samples.apps.nowinandroid.feature.search.impl
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -28,6 +28,11 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.EmptyQuery
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.LoadFailed
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Loading
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.SearchNotReady
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Success
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -56,29 +61,29 @@ class SearchViewModel @Inject constructor(
searchContentsRepository.getSearchContentsCount() searchContentsRepository.getSearchContentsCount()
.flatMapLatest { totalCount -> .flatMapLatest { totalCount ->
if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) { if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) {
flowOf(SearchResultUiState.SearchNotReady) flowOf(SearchNotReady)
} else { } else {
searchQuery.flatMapLatest { query -> searchQuery.flatMapLatest { query ->
if (query.trim().length < SEARCH_QUERY_MIN_LENGTH) { if (query.trim().length < SEARCH_QUERY_MIN_LENGTH) {
flowOf(SearchResultUiState.EmptyQuery) flowOf(EmptyQuery)
} else { } else {
getSearchContentsUseCase(query) getSearchContentsUseCase(query)
// Not using .asResult() here, because it emits Loading state every // Not using .asResult() here, because it emits Loading state every
// time the user types a letter in the search box, which flickers the screen. // time the user types a letter in the search box, which flickers the screen.
.map<UserSearchResult, SearchResultUiState> { data -> .map<UserSearchResult, SearchResultUiState> { data ->
SearchResultUiState.Success( Success(
topics = data.topics, topics = data.topics,
newsResources = data.newsResources, newsResources = data.newsResources,
) )
} }
.catch { emit(SearchResultUiState.LoadFailed) } .catch { emit(LoadFailed) }
} }
} }
} }
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchResultUiState.Loading, initialValue = Loading,
) )
val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> = val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> =

@ -0,0 +1,49 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.search.impl.navigation
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchRouteNav3
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchRoute
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object SearchModule {
@Provides
@IntoSet
fun provideSearchEntryProviderBuilder(
backStack: NiaBackStack,
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
entry<SearchRouteNav3> { key ->
SearchRoute(
onBackClick = backStack::removeLast,
onInterestsClick = key.onInterestsClick,
onTopicClick = backStack::navigateToInterests,
)
}
}
}

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.feature.search.api package com.google.samples.apps.nowinandroid.feature.search.impl
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper
@ -27,10 +27,10 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchCo
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.search.api.RecentSearchQueriesUiState.Success import com.google.samples.apps.nowinandroid.feature.search.impl.RecentSearchQueriesUiState.Success
import com.google.samples.apps.nowinandroid.feature.search.api.SearchResultUiState.EmptyQuery import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.EmptyQuery
import com.google.samples.apps.nowinandroid.feature.search.api.SearchResultUiState.Loading import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Loading
import com.google.samples.apps.nowinandroid.feature.search.api.SearchResultUiState.SearchNotReady import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.SearchNotReady
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -102,7 +102,7 @@ class SearchViewModelTest {
searchContentsRepository.addTopics(topicsTestData) searchContentsRepository.addTopics(topicsTestData)
val result = viewModel.searchResultUiState.value val result = viewModel.searchResultUiState.value
assertIs<SearchResultUiState.Success>(result) assertIs<com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Success>(result)
} }
@Test @Test

@ -17,7 +17,6 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.feature) alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
} }
android { android {
@ -28,6 +27,7 @@ dependencies {
implementation(projects.core.data) implementation(projects.core.data)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
implementation(projects.core.navigation)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(libs.bundles.androidx.compose.ui.test)

@ -22,12 +22,18 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable data class TopicRoute(val id: String) @Serializable data class TopicRoute(val id: String)
fun NiaBackStack.navigateToTopic(
topicId: String,
) {
navigate(TopicRoute(topicId))
}
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
navigate(route = TopicRoute(topicId)) { navigate(route = TopicRoute(topicId)) {
navOptions() navOptions()

@ -23,3 +23,15 @@ plugins {
android { android {
namespace = "com.google.samples.apps.nowinandroid.feature.topic.impl" namespace = "com.google.samples.apps.nowinandroid.feature.topic.impl"
} }
dependencies {
implementation(projects.core.data)
implementation(projects.core.navigation)
implementation(projects.feature.topic.api)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}

@ -0,0 +1,58 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.topic.impl.navigation
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel.Factory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityComponent::class)
object TopicModule {
@Provides
@IntoSet
fun provideTopicEntryProviderBuilder(
backStack: NiaBackStack,
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
entry<TopicRoute> { key ->
val id = key.id
TopicScreen(
showBackButton = true,
onBackClick = backStack::removeLast,
onTopicClick = backStack::navigateToTopic,
viewModel = hiltViewModel<TopicViewModel, Factory>(
key = id,
) { factory ->
factory.create(id)
},
)
}
}
}
Loading…
Cancel
Save