diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 8e4caabe8e..8c44d610e4 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -24,9 +24,12 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmar import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph +import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen +import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS +import com.google.samples.apps.nowinandroid.ui.NiaAppState /** * Top-level navigation graph. Navigation is organized as explained at @@ -37,10 +40,11 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen */ @Composable fun NiaNavHost( - navController: NavHostController, + appState: NiaAppState, modifier: Modifier = Modifier, startDestination: String = forYouNavigationRoute, ) { + val navController = appState.navController NavHost( navController = navController, startDestination = startDestination, @@ -49,7 +53,11 @@ fun NiaNavHost( // TODO: handle topic clicks from each top level destination forYouScreen(onTopicClick = {}) bookmarksScreen(onTopicClick = {}) - searchScreen(onBackClick = navController::popBackStack) + searchScreen( + onBackClick = navController::popBackStack, + onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, + onTopicClick = {} + ) interestsGraph( onTopicClick = { topicId -> navController.navigateToTopic(topicId) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 41563c2056..2388131fed 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -181,7 +181,7 @@ fun NiaApp( ) } - NiaNavHost(appState.navController) + NiaNavHost(appState) } // TODO: We may want to add padding or spacer when the snackbar is shown so that diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index e32aa1a57e..b546a72869 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -36,100 +37,100 @@ import kotlinx.datetime.toInstant * provides list of [UserNewsResource] for Composable previews. */ class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider> { - override val values: Sequence> - get() { - val userData: UserData = UserData( - bookmarkedNewsResources = setOf("1", "3"), - followedTopics = emptySet(), - themeBrand = ThemeBrand.ANDROID, - darkThemeConfig = DarkThemeConfig.DARK, - shouldHideOnboarding = true, - useDynamicColor = false, - ) + override val values: Sequence> = sequenceOf(newsResources) +} - val topics = listOf( - Topic( - id = "2", - name = "Headlines", - shortDescription = "News we want everyone to see", - longDescription = "Stay up to date with the latest events and announcements from Android!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", - url = "", - ), - Topic( - id = "3", - name = "UI", - shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", - longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", - url = "", - ), - Topic( - id = "4", - name = "Testing", - shortDescription = "CI, Espresso, TestLab, etc", - longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", - url = "", - ), - ) +object PreviewParameterData { - return sequenceOf( - listOf( - UserNewsResource( - newsResource = NewsResource( - id = "1", - title = "Android Basics with Compose", - content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", - url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", - headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", - publishDate = LocalDateTime( - year = 2022, - monthNumber = 5, - dayOfMonth = 4, - hour = 23, - minute = 0, - second = 0, - nanosecond = 0, - ).toInstant(TimeZone.UTC), - type = NewsResourceType.Codelab, - topics = listOf(topics[2]), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "2", - title = "Thanks for helping us reach 1M YouTube Subscribers", - content = "Thank you everyone for following the Now in Android series and everything the " + - "Android Developers YouTube channel has to offer. During the Android Developer " + - "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + - "thank you all.", - url = "https://youtu.be/-fJ6poHQrjM", - headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), - type = Video, - topics = topics.take(2), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "3", - title = "Transformations and customisations in the Paging Library", - content = "A demonstration of different operations that can be performed " + - "with Paging. Transformations like inserting separators, when to " + - "create a new pager, and customisation options for consuming " + - "PagingData.", - url = "https://youtu.be/ZARz0pjm5YM", - headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), - type = Video, - topics = listOf(topics[2]), - ), - userData = userData, - ), - ), - ) - } -} + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + followedTopics = emptySet(), + themeBrand = ThemeBrand.ANDROID, + darkThemeConfig = DarkThemeConfig.DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) + + val topics = listOf( + Topic( + id = "2", + name = "Headlines", + shortDescription = "News we want everyone to see", + longDescription = "Stay up to date with the latest events and announcements from Android!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", + url = "", + ), + Topic( + id = "3", + name = "UI", + shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", + longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", + url = "", + ), + Topic( + id = "4", + name = "Testing", + shortDescription = "CI, Espresso, TestLab, etc", + longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", + url = "", + ), + ) + + val newsResources = listOf( + UserNewsResource( + newsResource = NewsResource( + id = "1", + title = "Android Basics with Compose", + content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", + url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", + headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", + publishDate = LocalDateTime( + year = 2022, + monthNumber = 5, + dayOfMonth = 4, + hour = 23, + minute = 0, + second = 0, + nanosecond = 0, + ).toInstant(TimeZone.UTC), + type = NewsResourceType.Codelab, + topics = listOf(topics[2]), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "2", + title = "Thanks for helping us reach 1M YouTube Subscribers", + content = "Thank you everyone for following the Now in Android series and everything the " + + "Android Developers YouTube channel has to offer. During the Android Developer " + + "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + + "thank you all.", + url = "https://youtu.be/-fJ6poHQrjM", + headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), + type = Video, + topics = topics.take(2), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "3", + title = "Transformations and customisations in the Paging Library", + content = "A demonstration of different operations that can be performed " + + "with Paging. Transformations like inserting separators, when to " + + "create a new pager, and customisation options for consuming " + + "PagingData.", + url = "https://youtu.be/ZARz0pjm5YM", + headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), + type = Video, + topics = listOf(topics[2]), + ), + userData = userData, + ), + ) +} \ No newline at end of file diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 71667e4dcc..a453996268 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -35,6 +35,7 @@ fun TopicsTabContent( onTopicClick: (String) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, + withBottomSpacer: Boolean = true ) { LazyColumn( modifier = modifier @@ -56,8 +57,10 @@ fun TopicsTabContent( } } - item { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } } } diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index 1b6ae0f9c1..a630c90a40 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -26,3 +26,9 @@ android { namespace = "com.google.samples.apps.nowinandroid.feature.search" } +dependencies { + implementation(project(":feature:foryou")) + implementation(project(":feature:interests")) + implementation(libs.kotlinx.datetime) +} + diff --git a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt index 37fed8f85e..685562079f 100644 --- a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt +++ b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -17,13 +17,24 @@ package com.google.samples.apps.nowinandroid.feature.search import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onParent +import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData +import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import org.junit.Before import org.junit.Rule import org.junit.Test +import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR /** * UI test for checking the correct behaviour of the Search screen. @@ -33,12 +44,34 @@ class SearchScreenTest { @get:Rule val composeTestRule = createAndroidComposeRule() - private lateinit var clearSearchText: String + private lateinit var clearSearchContentDesc: String + private lateinit var followButtonContentDesc: String + private lateinit var unfollowButtonContentDesc: String + private lateinit var topicsString: String + private lateinit var updatesString: String + private lateinit var tryAnotherSearchString: String + + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + followedTopics = emptySet(), + themeBrand = ANDROID, + darkThemeConfig = DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) @Before fun setup() { composeTestRule.activity.apply { - clearSearchText = getString(R.string.clear_search_text) + clearSearchContentDesc = getString(R.string.clear_search_text_content_desc) + followButtonContentDesc = + getString(interestsR.string.card_follow_button_content_desc) + unfollowButtonContentDesc = + getString(interestsR.string.card_unfollow_button_content_desc) + topicsString = getString(R.string.topics) + updatesString = getString(R.string.updates) + tryAnotherSearchString = getString(R.string.try_another_search) + + " " + getString(R.string.interests) + " " + getString(R.string.to_browse_topics) } } @@ -49,10 +82,72 @@ class SearchScreenTest { } composeTestRule - .onNodeWithContentDescription(clearSearchText) + .onNodeWithContentDescription(clearSearchContentDesc) // The parent of the IconButton whose contentDescription matches the clearSearchText // should be the TextField for search .onParent() .assertIsFocused() } + + @Test + fun emptySearchResult_emptyScreenIsDisplayed() { + composeTestRule.setContent { + SearchScreen( + uiState = SearchResultUiState.Success() + ) + } + + composeTestRule + .onNodeWithText(tryAnotherSearchString) + .assertIsDisplayed() + } + + @Test + fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() { + composeTestRule.setContent { + SearchScreen( + uiState = SearchResultUiState.Success(topics = followableTopicTestData), + ) + } + + composeTestRule + .onNodeWithText(topicsString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[0].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[1].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[2].topic.name) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithContentDescription(followButtonContentDesc) + .assertCountEquals(2) + composeTestRule + .onAllNodesWithContentDescription(unfollowButtonContentDesc) + .assertCountEquals(1) + } + + @Test + fun searchResultWithNewsResources_firstNewsResourcesIsVisible() { + composeTestRule.setContent { + SearchScreen( + uiState = SearchResultUiState.Success(newsResources = newsResourcesTestData.map { + UserNewsResource( + newsResource = it, + userData = userData) + }), + ) + } + + composeTestRule + .onNodeWithText(updatesString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(newsResourcesTestData[0].title) + .assertIsDisplayed() + } } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt new file mode 100644 index 0000000000..e3343b7260 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt @@ -0,0 +1,31 @@ +/* + * 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 + * + * 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 + +import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource + +sealed interface SearchResultUiState { + object Loading : SearchResultUiState + + data class Success( + val topics: List = emptyList(), + val newsResources: List = emptyList(), + ) : SearchResultUiState { + fun isEmpty(): Boolean = topics.isEmpty() && newsResources.isEmpty() + } +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index 206aa609c7..4860d6b92e 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -16,24 +16,34 @@ package com.google.samples.apps.nowinandroid.feature.search +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.grid.GridCells.Adaptive +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,27 +51,51 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState 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.TrackScrollJank +import com.google.samples.apps.nowinandroid.core.ui.newsFeed +import com.google.samples.apps.nowinandroid.feature.foryou.ForYouViewModel +import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.interests.TopicsTabContent import com.google.samples.apps.nowinandroid.feature.search.R as searchR @Composable internal fun SearchRoute( modifier: Modifier = Modifier, onBackClick: () -> Unit, - viewModel: SearchViewModel = hiltViewModel(), + onInterestsClick: () -> Unit, + onTopicClick: (String) -> Unit, + interestsViewModel: InterestsViewModel = hiltViewModel(), + searchViewModel: SearchViewModel = hiltViewModel(), + forYouViewModel: ForYouViewModel = hiltViewModel(), ) { SearchScreen( modifier = modifier, onBackClick = onBackClick, - onSearchQueryChanged = viewModel::onSearchQueryChanged, + onFollowButtonClick = interestsViewModel::followTopic, + onInterestsClick = onInterestsClick, + onSearchQueryChanged = searchViewModel::onSearchQueryChanged, + onTopicClick = onTopicClick, + onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved ) } @@ -69,27 +103,161 @@ internal fun SearchRoute( internal fun SearchScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit = {}, + onFollowButtonClick: (String, Boolean) -> Unit = {_, _ -> }, + onInterestsClick: () -> Unit = {}, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = {_, _ -> }, onSearchQueryChanged: (String) -> Unit = {}, + onTopicClick: (String) -> Unit = {}, + uiState: SearchResultUiState = SearchResultUiState.Loading, ) { + val searchQuery = remember { mutableStateOf("") } TrackScreenViewEvent(screenName = "Search") - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Column(modifier = modifier) { Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) SearchToolbar( onBackClick = onBackClick, onSearchQueryChanged = onSearchQueryChanged, + searchQuery = searchQuery, ) + when (uiState) { + SearchResultUiState.Loading -> Unit + is SearchResultUiState.Success -> { + if (uiState.isEmpty()) { + EmptySearchResultBody( + onInterestsClick = onInterestsClick, + searchQuery = searchQuery, + ) + } else { + SearchResultBody( + topics = uiState.topics, + onFollowButtonClick = onFollowButtonClick, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onTopicClick = onTopicClick, + newsResources = uiState.newsResources, + ) + } + } + } Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } } +@Composable +fun EmptySearchResultBody( + onInterestsClick: () -> Unit = {}, + searchQuery: MutableState, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val queryValue = searchQuery.value + val message = stringResource(id = searchR.string.search_result_not_found, queryValue) + val start = message.indexOf(queryValue) + Text( + text = AnnotatedString( + text = message, + spanStyles = listOf( + AnnotatedString.Range( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, + end = start + queryValue.length, + ), + ), + ), + modifier = Modifier.padding(horizontal = 36.dp, vertical = 24.dp), + ) + val interests = stringResource(id = searchR.string.interests) + val tryAnotherSearchString = buildAnnotatedString { + append(stringResource(id = searchR.string.try_another_search)) + append(" ") + withStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + ), + ) { + pushStringAnnotation(tag = interests, annotation = interests) + append(interests) + } + append(" ") + append(stringResource(id = searchR.string.to_browse_topics)) + } + ClickableText( + text = tryAnotherSearchString, + modifier = Modifier + .padding(start = 36.dp, end = 36.dp, bottom = 24.dp) + .clickable {}, + ) { offset -> + tryAnotherSearchString.getStringAnnotations(start = offset, end = offset) + .firstOrNull() + ?.let { + onInterestsClick() + } + } + + } +} + +@Composable +private fun SearchResultBody( + topics: List, + newsResources: List, + onFollowButtonClick: (String, Boolean) -> Unit, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = {_, _ -> }, + onTopicClick: (String) -> Unit = {} +) { + if (topics.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.topics)) + } + }, + modifier = Modifier.padding(16.dp), + ) + TopicsTabContent( + topics = topics, + onTopicClick = onTopicClick, + onFollowButtonClick = onFollowButtonClick, + withBottomSpacer = false + ) + } + + if (newsResources.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.updates)) + } + }, + modifier = Modifier.padding(16.dp), + ) + + val state = rememberLazyGridState() + TrackScrollJank(scrollableState = state, stateName = "search:newsResource") + LazyVerticalGrid( + columns = Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .testTag("search:newsResources"), + state = state, + ) { + newsFeed( + feedState = NewsFeedUiState.Success(feed = newsResources), + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onTopicClick = onTopicClick + ) + } + } +} + @Composable private fun SearchToolbar( modifier: Modifier = Modifier, onBackClick: () -> Unit = {}, onSearchQueryChanged: (String) -> Unit = {}, + searchQuery: MutableState = mutableStateOf(""), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -103,14 +271,19 @@ private fun SearchToolbar( ), ) } - SearchTextField(onSearchQueryChanged = onSearchQueryChanged) + SearchTextField( + onSearchQueryChanged = onSearchQueryChanged, + searchQuery = searchQuery, + ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) { - val textState = remember { mutableStateOf("") } +private fun SearchTextField( + onSearchQueryChanged: (String) -> Unit, + searchQuery: MutableState, +) { val focusRequester = remember { FocusRequester() } TextField( colors = TextFieldDefaults.textFieldColors( @@ -128,26 +301,26 @@ private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) { ) }, trailingIcon = { - IconButton(onClick = { textState.value = "" }) { + IconButton(onClick = { searchQuery.value = "" }) { Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.clear_search_text, + id = searchR.string.clear_search_text_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) } }, onValueChange = { - textState.value = it + searchQuery.value = it onSearchQueryChanged(it) }, modifier = Modifier .fillMaxWidth() - .padding(12.dp) + .padding(16.dp) .focusRequester(focusRequester), shape = RoundedCornerShape(32.dp), - value = textState.value, + value = searchQuery.value, ) LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -162,10 +335,22 @@ private fun SearchToolbarPreview() { } } +@Preview +@Composable +private fun EmptySearchResultColumnPreview() { + NiaTheme { + val searchQuery = remember { mutableStateOf("C++") } + EmptySearchResultBody(searchQuery = searchQuery) + } +} + @DevicePreviews @Composable -private fun SearchScreenPreview() { +private fun SearchScreenPreview( + @PreviewParameter(SearchResultUiStatePreviewParameterProvider::class) + searchResultUiState: SearchResultUiState, +) { NiaTheme { - SearchScreen() + SearchScreen(uiState = searchResultUiState) } } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..044428e075 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt @@ -0,0 +1,36 @@ +/* + * 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 + * + * 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 + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics + +/* ktlint-disable max-line-length */ +/** + * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) + * provides list of [SearchResultUiState] for Composable previews. + */ +class SearchResultUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf(SearchResultUiState.Success( + topics = topics.mapIndexed { i, topic -> + FollowableTopic(topic = topic, isFollowed = i % 2 == 0) + }, + newsResources = newsResources, + )) +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt index 5122ce0c77..79e5048527 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt @@ -28,10 +28,18 @@ fun NavController.navigateToSearch(navOptions: NavOptions? = null) { this.navigate(searchRoute, navOptions) } -fun NavGraphBuilder.searchScreen(onBackClick: () -> Unit) { +fun NavGraphBuilder.searchScreen( + onBackClick: () -> Unit, + 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(route = searchRoute) { - SearchRoute(onBackClick = onBackClick) + SearchRoute( + onBackClick = onBackClick, + onInterestsClick = onInterestsClick, + onTopicClick = onTopicClick + ) } } diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index e97f970891..374481d866 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -16,5 +16,11 @@ --> Search - Clear search text + Clear search text + Sorry, there is no content found for your search \"%1$s\" + Try another search or explorer + Interests + to browse topics + Topics + Updates \ No newline at end of file