diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt index d43101078..68f7c6b1a 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -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( diff --git a/feature/bookmarks/api/build.gradle.kts b/feature/bookmarks/api/build.gradle.kts index f12a39f7d..9ab1fbd05 100644 --- a/feature/bookmarks/api/build.gradle.kts +++ b/feature/bookmarks/api/build.gradle.kts @@ -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) -} diff --git a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavigation.kt b/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavigation.kt index 23b48b17a..132424350 100644 --- a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavigation.kt +++ b/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavigation.kt @@ -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(onTopicClick, onShowSnackbar) - } +// composable { +// BookmarksRoute( +// onTopicClick, +// onShowSnackbar +// ) +// } } diff --git a/feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml b/feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml deleted file mode 100644 index 64bbfbd23..000000000 --- a/feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/feature/bookmarks/impl/build.gradle.kts b/feature/bookmarks/impl/build.gradle.kts index f46554044..2f62df27d 100644 --- a/feature/bookmarks/impl/build.gradle.kts +++ b/feature/bookmarks/impl/build.gradle.kts @@ -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) +} diff --git a/feature/bookmarks/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksScreenTest.kt b/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt similarity index 97% rename from feature/bookmarks/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksScreenTest.kt rename to feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt index 78f29f92a..56577976d 100644 --- a/feature/bookmarks/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksScreenTest.kt +++ b/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt @@ -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() } diff --git a/feature/bookmarks/api/src/main/AndroidManifest.xml b/feature/bookmarks/impl/src/main/AndroidManifest.xml similarity index 100% rename from feature/bookmarks/api/src/main/AndroidManifest.xml rename to feature/bookmarks/impl/src/main/AndroidManifest.xml diff --git a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksScreen.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt similarity index 98% rename from feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksScreen.kt rename to feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt index 019fb4609..e7383be3a 100644 --- a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksScreen.kt +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt @@ -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, diff --git a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksViewModel.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt similarity index 97% rename from feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksViewModel.kt rename to feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt index 7b8945eb2..f36c9d31f 100644 --- a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksViewModel.kt +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt @@ -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 diff --git a/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksEntryProvider.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksEntryProvider.kt new file mode 100644 index 000000000..0bfeb3668 --- /dev/null +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksEntryProvider.kt @@ -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.() -> @JvmSuppressWildcards Unit = { + entry { + val snackbarHostState = LocalSnackbarHostState.current + BookmarksRoute( + onTopicClick = backStack::navigateToTopic, + onShowSnackbar = { message, action -> + snackbarHostState.showSnackbar( + message = message, + actionLabel = action, + duration = Short, + ) == ActionPerformed + } + ) + } + } +} + +val LocalSnackbarHostState = compositionLocalOf { + error("host state should be initialzied at runtime") +} diff --git a/feature/bookmarks/impl/src/main/res/drawable/feature_bookmarks_impl_mg_empty_bookmarks.xml b/feature/bookmarks/impl/src/main/res/drawable/feature_bookmarks_impl_mg_empty_bookmarks.xml new file mode 100644 index 000000000..bc12d4325 --- /dev/null +++ b/feature/bookmarks/impl/src/main/res/drawable/feature_bookmarks_impl_mg_empty_bookmarks.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/feature/bookmarks/api/src/main/res/values/strings.xml b/feature/bookmarks/impl/src/main/res/values/strings.xml similarity index 59% rename from feature/bookmarks/api/src/main/res/values/strings.xml rename to feature/bookmarks/impl/src/main/res/values/strings.xml index 98f4b4a8d..6aa996a1a 100644 --- a/feature/bookmarks/api/src/main/res/values/strings.xml +++ b/feature/bookmarks/impl/src/main/res/values/strings.xml @@ -15,10 +15,10 @@ limitations under the License. --> - Saved - Loading saved… - No saved updates - Updates you save will be stored here\nto read later - Bookmark removed - UNDO + Saved + Loading saved… + No saved updates + Updates you save will be stored here\nto read later + Bookmark removed + UNDO diff --git a/feature/bookmarks/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksViewModelTest.kt b/feature/bookmarks/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModelTest.kt similarity index 97% rename from feature/bookmarks/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksViewModelTest.kt rename to feature/bookmarks/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModelTest.kt index ae3b488c7..66ce0744f 100644 --- a/feature/bookmarks/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/BookmarksViewModelTest.kt +++ b/feature/bookmarks/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModelTest.kt @@ -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 diff --git a/feature/foryou/api/build.gradle.kts b/feature/foryou/api/build.gradle.kts index 2ee38bfae..45eb87d89 100644 --- a/feature/foryou/api/build.gradle.kts +++ b/feature/foryou/api/build.gradle.kts @@ -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 { diff --git a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavigation.kt b/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavigation.kt index a4b276983..e11b4d26e 100644 --- a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavigation.kt +++ b/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavigation.kt @@ -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(startDestination = ForYouRoute) { - composable( - 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(startDestination = ForYouRoute) { +// composable( +// 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() +// } } diff --git a/feature/foryou/api/src/main/res/values/strings.xml b/feature/foryou/api/src/main/res/values/strings.xml index 4694eb444..f0595944f 100644 --- a/feature/foryou/api/src/main/res/values/strings.xml +++ b/feature/foryou/api/src/main/res/values/strings.xml @@ -21,5 +21,4 @@ Navigate up What are you interested in? Updates from topics you follow will appear here. Follow some things to get started. - - + \ No newline at end of file diff --git a/feature/foryou/impl/build.gradle.kts b/feature/foryou/impl/build.gradle.kts index 37f49d304..00a3c1419 100644 --- a/feature/foryou/impl/build.gradle.kts +++ b/feature/foryou/impl/build.gradle.kts @@ -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 { } \ No newline at end of file +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) +} \ No newline at end of file diff --git a/feature/foryou/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreenTest.kt b/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt similarity index 94% rename from feature/foryou/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreenTest.kt rename to feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt index 46905fd8b..74378b699 100644 --- a/feature/foryou/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreenTest.kt +++ b/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt @@ -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 = { _, _ -> }, diff --git a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreen.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt similarity index 99% rename from feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreen.kt rename to feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt index 0b32cc6a6..a7be6ae31 100644 --- a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreen.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt @@ -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( diff --git a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouViewModel.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt similarity index 98% rename from feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouViewModel.kt rename to feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt index 6fb44a5f5..c54551c0b 100644 --- a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouViewModel.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt @@ -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 diff --git a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/OnboardingUiState.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/OnboardingUiState.kt similarity index 95% rename from feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/OnboardingUiState.kt rename to feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/OnboardingUiState.kt index cd2197bcb..d31749bb5 100644 --- a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/OnboardingUiState.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/OnboardingUiState.kt @@ -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 diff --git a/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouEntryProvider.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouEntryProvider.kt new file mode 100644 index 000000000..05863011c --- /dev/null +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouEntryProvider.kt @@ -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.() -> @JvmSuppressWildcards Unit = { + entry { + ForYouScreen( + onTopicClick = backStack::navigateToTopic + ) + } + } +} \ No newline at end of file diff --git a/feature/foryou/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreenScreenshotTests.kt b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt similarity index 94% rename from feature/foryou/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreenScreenshotTests.kt rename to feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt index 447472d1c..d0d73860e 100644 --- a/feature/foryou/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouScreenScreenshotTests.kt +++ b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt @@ -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, ), diff --git a/feature/foryou/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouViewModelTest.kt b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModelTest.kt similarity index 99% rename from feature/foryou/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouViewModelTest.kt rename to feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModelTest.kt index b8a4ef86b..5008b484c 100644 --- a/feature/foryou/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/ForYouViewModelTest.kt +++ b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModelTest.kt @@ -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 diff --git a/feature/interests/api/build.gradle.kts b/feature/interests/api/build.gradle.kts index a3627fac6..db804484e 100644 --- a/feature/interests/api/build.gradle.kts +++ b/feature/interests/api/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavigation.kt b/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavigation.kt index f509ba6b8..f4ae878f7 100644 --- a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavigation.kt +++ b/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavigation.kt @@ -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)) +} diff --git a/feature/interests/impl/build.gradle.kts b/feature/interests/impl/build.gradle.kts index a83efdc4a..6419f4b1e 100644 --- a/feature/interests/impl/build.gradle.kts +++ b/feature/interests/impl/build.gradle.kts @@ -21,4 +21,25 @@ plugins { } android { namespace = "com.google.samples.apps.nowinandroid.feature.interests.impl" -} \ No newline at end of file +} + +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) +} diff --git a/feature/interests/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/api/InterestsScreenTest.kt b/feature/interests/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreenTest.kt similarity index 95% rename from feature/interests/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/api/InterestsScreenTest.kt rename to feature/interests/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreenTest.kt index 2286dadb2..8a10a478b 100644 --- a/feature/interests/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/api/InterestsScreenTest.kt +++ b/feature/interests/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreenTest.kt @@ -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 diff --git a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/Interests2PaneViewModel.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/Interests2PaneViewModel.kt new file mode 100644 index 000000000..6be41d555 --- /dev/null +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/Interests2PaneViewModel.kt @@ -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() + val selectedTopicId: StateFlow = savedStateHandle.getStateFlow( + key = TOPIC_ID_KEY, + initialValue = route.initialTopicId, + ) + + fun onTopicClick(topicId: String?) { + savedStateHandle[TOPIC_ID_KEY] = topicId + } +} diff --git a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsEntryProvider.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsEntryProvider.kt new file mode 100644 index 000000000..e485aed89 --- /dev/null +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsEntryProvider.kt @@ -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.() -> @JvmSuppressWildcards Unit = { + entry { key -> + InterestsListDetailScreen() + } + } +} \ No newline at end of file diff --git a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsListDetailScreen.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsListDetailScreen.kt new file mode 100644 index 000000000..357afd83e --- /dev/null +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsListDetailScreen.kt @@ -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 { + 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(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( + 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 ThreePaneScaffoldNavigator.isListPaneVisible(): Boolean = + scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun ThreePaneScaffoldNavigator.isDetailPaneVisible(): Boolean = + scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded diff --git a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/InterestsScreen.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt similarity index 97% rename from feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/InterestsScreen.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt index 8f50ae638..24e698303 100644 --- a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/InterestsScreen.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt @@ -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( diff --git a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/InterestsViewModel.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt similarity index 97% rename from feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/InterestsViewModel.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt index 5c7b37c32..2fe015c03 100644 --- a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/InterestsViewModel.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt @@ -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 diff --git a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/TabContent.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt similarity index 98% rename from feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/TabContent.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt index 5685ec6ab..5d86e8de7 100644 --- a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/TabContent.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt @@ -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 diff --git a/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsListDetailScreenTest.kt b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsListDetailScreenTest.kt new file mode 100644 index 000000000..45d3e8507 --- /dev/null +++ b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsListDetailScreenTest.kt @@ -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() + + @Inject + lateinit var topicsRepository: TopicsRepository + + /** Convenience function for getting all topics during tests, */ + private fun getTopics(): List = 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 = + ReadOnlyProperty { _, _ -> activity.getString(resId) } diff --git a/feature/interests/api/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/api/InterestsViewModelTest.kt b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt similarity index 99% rename from feature/interests/api/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/api/InterestsViewModelTest.kt rename to feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt index 55a1ded1a..b9c06d38b 100644 --- a/feature/interests/api/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/api/InterestsViewModelTest.kt +++ b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt @@ -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 diff --git a/feature/search/api/build.gradle.kts b/feature/search/api/build.gradle.kts index 741a87fff..771cac5ca 100644 --- a/feature/search/api/build.gradle.kts +++ b/feature/search/api/build.gradle.kts @@ -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) diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavigation.kt b/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavigation.kt index b45956b29..0ddd81f27 100644 --- a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavigation.kt +++ b/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavigation.kt @@ -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 { - 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 { +// com.google.samples.apps.nowinandroid.feature.search.impl.SearchRoute( +// onBackClick = onBackClick, +// onInterestsClick = onInterestsClick, +// onTopicClick = onTopicClick, +// ) +// } } diff --git a/feature/search/impl/build.gradle.kts b/feature/search/impl/build.gradle.kts index abb0feacd..9c189c03f 100644 --- a/feature/search/impl/build.gradle.kts +++ b/feature/search/impl/build.gradle.kts @@ -22,4 +22,17 @@ 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) } \ No newline at end of file diff --git a/feature/search/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchScreenTest.kt b/feature/search/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreenTest.kt similarity index 93% rename from feature/search/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchScreenTest.kt rename to feature/search/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreenTest.kt index a86de6ab3..3cb93530f 100644 --- a/feature/search/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchScreenTest.kt +++ b/feature/search/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreenTest.kt @@ -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) } } diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/RecentSearchQueriesUiState.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/RecentSearchQueriesUiState.kt similarity index 93% rename from feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/RecentSearchQueriesUiState.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/RecentSearchQueriesUiState.kt index 3e5d704a5..5b8516664 100644 --- a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/RecentSearchQueriesUiState.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/RecentSearchQueriesUiState.kt @@ -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 diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchResultUiState.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchResultUiState.kt similarity index 96% rename from feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchResultUiState.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchResultUiState.kt index 27b4003bf..7a6f37087 100644 --- a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchResultUiState.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchResultUiState.kt @@ -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 diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchScreen.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt similarity index 97% rename from feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchScreen.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt index 28c7c3bfc..c530b274a 100644 --- a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchScreen.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt @@ -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( diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchUiStatePreviewParameterProvider.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchUiStatePreviewParameterProvider.kt similarity index 90% rename from feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchUiStatePreviewParameterProvider.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchUiStatePreviewParameterProvider.kt index cd31ad8ad..680c97d41 100644 --- a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchUiStatePreviewParameterProvider.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchUiStatePreviewParameterProvider.kt @@ -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) diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchViewModel.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt similarity index 88% rename from feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchViewModel.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt index ed1ea1171..99e403d1a 100644 --- a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchViewModel.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt @@ -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 { 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 = diff --git a/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchEntryProvider.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchEntryProvider.kt new file mode 100644 index 000000000..2cab3139a --- /dev/null +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchEntryProvider.kt @@ -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.() -> @JvmSuppressWildcards Unit = { + entry { key -> + SearchRoute( + onBackClick = backStack::removeLast, + onInterestsClick = key.onInterestsClick, + onTopicClick = backStack::navigateToInterests, + ) + } + } +} \ No newline at end of file diff --git a/feature/search/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchViewModelTest.kt b/feature/search/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModelTest.kt similarity index 92% rename from feature/search/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchViewModelTest.kt rename to feature/search/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModelTest.kt index 7b719b1cc..8d86f38ce 100644 --- a/feature/search/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/SearchViewModelTest.kt +++ b/feature/search/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModelTest.kt @@ -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(result) + assertIs(result) } @Test diff --git a/feature/topic/api/build.gradle.kts b/feature/topic/api/build.gradle.kts index 00678fa35..7abbfea82 100644 --- a/feature/topic/api/build.gradle.kts +++ b/feature/topic/api/build.gradle.kts @@ -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) diff --git a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicNavigation.kt b/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicNavigation.kt index 315389908..2594eda33 100644 --- a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicNavigation.kt +++ b/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicNavigation.kt @@ -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() diff --git a/feature/topic/impl/build.gradle.kts b/feature/topic/impl/build.gradle.kts index 528ec4e4a..e79dade7a 100644 --- a/feature/topic/impl/build.gradle.kts +++ b/feature/topic/impl/build.gradle.kts @@ -22,4 +22,16 @@ 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) } \ No newline at end of file diff --git a/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/navigation/TopicEntryProvider.kt b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/navigation/TopicEntryProvider.kt new file mode 100644 index 000000000..784d74a05 --- /dev/null +++ b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/navigation/TopicEntryProvider.kt @@ -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.() -> @JvmSuppressWildcards Unit = { + entry { key -> + val id = key.id + TopicScreen( + showBackButton = true, + onBackClick = backStack::removeLast, + onTopicClick = backStack::navigateToTopic, + viewModel = hiltViewModel( + key = id, + ) { factory -> + factory.create(id) + }, + ) + } + } +} \ No newline at end of file