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.interests.api.navigation.InterestsRoute
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.search.api.R as searchR
@ -62,8 +62,8 @@ enum class TopLevelDestination(
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
iconTextId = bookmarksR.string.feature_bookmarks_impl_title,
titleTextId = bookmarksR.string.feature_bookmarks_impl_title,
route = BookmarksRoute::class,
),
INTERESTS(

@ -16,19 +16,8 @@
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
}
android {
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.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.BookmarksRoute
import kotlinx.serialization.Serializable
@Serializable object BookmarksRoute
@ -32,7 +30,10 @@ fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
// composable<BookmarksRoute> {
// BookmarksRoute(
// onTopicClick,
// onShowSnackbar
// )
// }
}

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

@ -23,4 +23,14 @@ android {
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.
*/
package com.google.samples.apps.nowinandroid.feature.bookmarks.api
package com.google.samples.apps.nowinandroid.feature.bookmarks.impl
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
@ -63,7 +63,7 @@ class BookmarksScreenTest {
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.feature_bookmarks_loading),
composeTestRule.activity.resources.getString(R.string.feature_bookmarks_api_loading),
)
.assertExists()
}
@ -160,13 +160,13 @@ class BookmarksScreenTest {
composeTestRule
.onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_empty_error),
composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error),
)
.assertExists()
composeTestRule
.onNodeWithText(
composeTestRule.activity.getString(R.string.feature_bookmarks_empty_description),
composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description),
)
.assertExists()
}

@ -14,7 +14,7 @@
* 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.compose.foundation.Image
@ -112,8 +112,8 @@ internal fun BookmarksScreen(
undoBookmarkRemoval: () -> Unit = {},
clearUndoState: () -> Unit = {},
) {
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_api_undo)
val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_impl_removed)
val undoText = stringResource(id = R.string.feature_bookmarks_impl_undo)
LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) {
@ -155,7 +155,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
.fillMaxWidth()
.wrapContentSize()
.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
Image(
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,
contentDescription = null,
)
@ -236,7 +236,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(48.dp))
Text(
text = stringResource(id = R.string.feature_bookmarks_api_empty_error),
text = stringResource(id = R.string.feature_bookmarks_impl_empty_error),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
@ -246,7 +246,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.feature_bookmarks_api_empty_description),
text = stringResource(id = R.string.feature_bookmarks_impl_empty_description),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,

@ -14,7 +14,7 @@
* 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.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.
-->
<resources>
<string name="feature_bookmarks_api_title">Saved</string>
<string name="feature_bookmarks_api_loading">Loading saved…</string>
<string name="feature_bookmarks_api_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_api_removed">Bookmark removed</string>
<string name="feature_bookmarks_api_undo">UNDO</string>
<string name="feature_bookmarks_impl_title">Saved</string>
<string name="feature_bookmarks_impl_loading">Loading saved…</string>
<string name="feature_bookmarks_impl_empty_error">No saved updates</string>
<string name="feature_bookmarks_impl_empty_description">Updates you save will be stored here\nto read later</string>
<string name="feature_bookmarks_impl_removed">Bookmark removed</string>
<string name="feature_bookmarks_impl_undo">UNDO</string>
</resources>

@ -14,7 +14,7 @@
* 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.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.ui.NewsFeedUiState.Loading
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.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher

@ -16,9 +16,6 @@
plugins {
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 {

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

@ -21,5 +21,4 @@
<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_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string>
</resources>

@ -17,10 +17,27 @@
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.roborazzi)
}
android {
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.
*/
package com.google.samples.apps.nowinandroid.feature.foryou.api
package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.activity.ComponentActivity
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.userNewsResourcesTestData
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.Test
@ -153,12 +154,12 @@ class ForYouScreenTest {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(
// Follow one topic
topics = followableTopicTestData.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
},
),
OnboardingUiState.Shown(
// Follow one topic
topics = followableTopicTestData.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
},
),
feedState = NewsFeedUiState.Success(
feed = emptyList(),
),
@ -200,7 +201,9 @@ class ForYouScreenTest {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(topics = followableTopicTestData),
OnboardingUiState.Shown(
topics = followableTopicTestData
),
feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },

@ -14,7 +14,7 @@
* 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.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.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.foryou.api.R
@Composable
internal fun ForYouScreen(

@ -14,7 +14,7 @@
* 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.ViewModel

@ -14,7 +14,7 @@
* 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

@ -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.
*/
package com.google.samples.apps.nowinandroid.feature.foryou.api
package com.google.samples.apps.nowinandroid.feature.foryou.impl
import androidx.activity.ComponentActivity
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.Success
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.api.OnboardingUiState.NotShown
import com.google.samples.apps.nowinandroid.feature.foryou.api.OnboardingUiState.Shown
import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.NotShown
import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.Shown
import dagger.hilt.android.testing.HiltTestApplication
import org.hamcrest.Matchers
import org.junit.Before
@ -97,7 +96,7 @@ class ForYouScreenScreenshotTests {
NiaTheme {
ForYouScreen(
isSyncing = false,
onboardingUiState = Loading,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -194,7 +193,7 @@ class ForYouScreenScreenshotTests {
NiaTheme {
ForYouScreen(
isSyncing = true,
onboardingUiState = Loading,
onboardingUiState = OnboardingUiState.Loading,
feedState = Success(
feed = userNewsResources,
),

@ -14,7 +14,7 @@
* 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 com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent

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

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

@ -22,3 +22,24 @@ plugins {
android {
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.
*/
package com.google.samples.apps.nowinandroid.interests
package com.google.samples.apps.nowinandroid.feature.interests.impl
import androidx.activity.ComponentActivity
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.onNodeWithText
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.Rule
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.
*/
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.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.FollowableTopicPreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.feature.interests.api.R
@Composable
fun InterestsRoute(

@ -14,7 +14,7 @@
* 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.ViewModel

@ -14,7 +14,7 @@
* 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.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.
*/
package com.google.samples.apps.nowinandroid.interests.api
package com.google.samples.apps.nowinandroid.interests.impl
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.testing.invoke

@ -16,8 +16,6 @@
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.library.jacoco)
}
android {
@ -27,6 +25,7 @@ android {
dependencies {
implementation(projects.core.data)
implementation(projects.core.domain)
implementation(projects.core.navigation)
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.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.search.api.SearchRoute
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import kotlinx.serialization.Serializable
@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) =
navigate(SearchRoute, navOptions)
@ -33,13 +40,13 @@ fun NavGraphBuilder.searchScreen(
onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
// TODO: Handle back stack for each top-level destination. At the moment each top-level
// destination may have own search screen's back stack.
composable<SearchRoute> {
com.google.samples.apps.nowinandroid.feature.search.api.SearchRoute(
onBackClick = onBackClick,
onInterestsClick = onInterestsClick,
onTopicClick = onTopicClick,
)
}
// // TODO: Handle back stack for each top-level destination. At the moment each top-level
// // destination may have own search screen's back stack.
// composable<SearchRoute> {
// com.google.samples.apps.nowinandroid.feature.search.impl.SearchRoute(
// onBackClick = onBackClick,
// onInterestsClick = onInterestsClick,
// onTopicClick = onTopicClick,
// )
// }
}

@ -23,3 +23,16 @@ plugins {
android {
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.
*/
package com.google.samples.apps.nowinandroid.feature.search.api
package com.google.samples.apps.nowinandroid.feature.search.impl
import androidx.activity.ComponentActivity
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.newsResourcesTestData
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.Rule
import org.junit.Test
@ -70,17 +71,17 @@ class SearchScreenTest {
@Before
fun setup() {
composeTestRule.activity.apply {
clearSearchContentDesc = getString(R.string.feature_search_clear_search_text_content_desc)
clearRecentSearchesContentDesc = getString(R.string.feature_search_clear_recent_searches_content_desc)
clearSearchContentDesc = getString(R.string.feature_search_api_clear_search_text_content_desc)
clearRecentSearchesContentDesc = getString(R.string.feature_search_api_clear_recent_searches_content_desc)
followButtonContentDesc =
getString(string.core_ui_interests_card_follow_button_content_desc)
unfollowButtonContentDesc =
getString(string.core_ui_interests_card_unfollow_button_content_desc)
topicsString = getString(R.string.feature_search_topics)
updatesString = getString(R.string.feature_search_updates)
tryAnotherSearchString = getString(R.string.feature_search_try_another_search) +
" " + getString(R.string.feature_search_interests) + " " + getString(R.string.feature_search_to_browse_topics)
searchNotReadyString = getString(R.string.feature_search_not_ready)
topicsString = getString(R.string.feature_search_api_topics)
updatesString = getString(R.string.feature_search_api_updates)
tryAnotherSearchString = getString(R.string.feature_search_api_try_another_search) +
" " + getString(R.string.feature_search_api_interests) + " " + getString(R.string.feature_search_api_to_browse_topics)
searchNotReadyString = getString(R.string.feature_search_api_not_ready)
}
}

@ -14,7 +14,7 @@
* 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

@ -14,7 +14,7 @@
* 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.UserNewsResource

@ -14,7 +14,7 @@
* 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.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.TrackScreenViewEvent
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
@Composable
@ -127,7 +131,7 @@ internal fun SearchRoute(
internal fun SearchScreen(
modifier: Modifier = Modifier,
searchQuery: String = "",
recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading,
recentSearchesUiState: RecentSearchQueriesUiState = Loading,
searchResultUiState: SearchResultUiState = SearchResultUiState.Loading,
onSearchQueryChanged: (String) -> Unit = {},
onSearchTriggered: (String) -> Unit = {},
@ -150,11 +154,11 @@ internal fun SearchScreen(
)
when (searchResultUiState) {
SearchResultUiState.Loading,
SearchResultUiState.LoadFailed,
LoadFailed,
-> Unit
SearchResultUiState.SearchNotReady -> SearchNotReadyBody()
SearchResultUiState.EmptyQuery,
SearchNotReady -> SearchNotReadyBody()
EmptyQuery,
-> {
if (recentSearchesUiState is RecentSearchQueriesUiState.Success) {
RecentSearchesBody(

@ -16,13 +16,13 @@
@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 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.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)

@ -14,7 +14,7 @@
* 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.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.GetSearchContentsUseCase
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 kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -56,29 +61,29 @@ class SearchViewModel @Inject constructor(
searchContentsRepository.getSearchContentsCount()
.flatMapLatest { totalCount ->
if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) {
flowOf(SearchResultUiState.SearchNotReady)
flowOf(SearchNotReady)
} else {
searchQuery.flatMapLatest { query ->
if (query.trim().length < SEARCH_QUERY_MIN_LENGTH) {
flowOf(SearchResultUiState.EmptyQuery)
flowOf(EmptyQuery)
} else {
getSearchContentsUseCase(query)
// Not using .asResult() here, because it emits Loading state every
// time the user types a letter in the search box, which flickers the screen.
.map<UserSearchResult, SearchResultUiState> { data ->
SearchResultUiState.Success(
Success(
topics = data.topics,
newsResources = data.newsResources,
)
}
.catch { emit(SearchResultUiState.LoadFailed) }
.catch { emit(LoadFailed) }
}
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchResultUiState.Loading,
initialValue = Loading,
)
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.
*/
package com.google.samples.apps.nowinandroid.feature.search.api
package com.google.samples.apps.nowinandroid.feature.search.impl
import androidx.lifecycle.SavedStateHandle
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.emptyUserData
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.api.SearchResultUiState.EmptyQuery
import com.google.samples.apps.nowinandroid.feature.search.api.SearchResultUiState.Loading
import com.google.samples.apps.nowinandroid.feature.search.api.SearchResultUiState.SearchNotReady
import com.google.samples.apps.nowinandroid.feature.search.impl.RecentSearchQueriesUiState.Success
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.EmptyQuery
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Loading
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.SearchNotReady
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@ -102,7 +102,7 @@ class SearchViewModelTest {
searchContentsRepository.addTopics(topicsTestData)
val result = viewModel.searchResultUiState.value
assertIs<SearchResultUiState.Success>(result)
assertIs<com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Success>(result)
}
@Test

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

@ -22,12 +22,18 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.composable
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.TopicScreen
import kotlinx.serialization.Serializable
@Serializable data class TopicRoute(val id: String)
fun NiaBackStack.navigateToTopic(
topicId: String,
) {
navigate(TopicRoute(topicId))
}
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
navigate(route = TopicRoute(topicId)) {
navOptions()

@ -23,3 +23,15 @@ plugins {
android {
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