Implement SearchScreen

- Empty search screen if the search result is empty
- Topics contents that reuses the TopicsTabContent for InterestsScreen
- Updates contents that reuses the newsFeed for ForYouScreen

TODO: Needs to add RecentSearch
Change-Id: I19179336a60b5165e8b59508fb03dd8f55a16f96
search_screen
Takeshi Hagikura 2 years ago
parent 2eddf0ba7e
commit 13e6cc4f2b

@ -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.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen 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.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.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic 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.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 * 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 @Composable
fun NiaNavHost( fun NiaNavHost(
navController: NavHostController, appState: NiaAppState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute, startDestination: String = forYouNavigationRoute,
) { ) {
val navController = appState.navController
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
@ -49,7 +53,11 @@ fun NiaNavHost(
// TODO: handle topic clicks from each top level destination // TODO: handle topic clicks from each top level destination
forYouScreen(onTopicClick = {}) forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {}) bookmarksScreen(onTopicClick = {})
searchScreen(onBackClick = navController::popBackStack) searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = {}
)
interestsGraph( interestsGraph(
onTopicClick = { topicId -> onTopicClick = { topicId ->
navController.navigateToTopic(topicId) navController.navigateToTopic(topicId)

@ -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 // TODO: We may want to add padding or spacer when the snackbar is shown so that

@ -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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.model.data.UserData
import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@ -36,100 +37,100 @@ import kotlinx.datetime.toInstant
* provides list of [UserNewsResource] for Composable previews. * provides list of [UserNewsResource] for Composable previews.
*/ */
class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider<List<UserNewsResource>> { class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider<List<UserNewsResource>> {
override val values: Sequence<List<UserNewsResource>> override val values: Sequence<List<UserNewsResource>> = sequenceOf(newsResources)
get() { }
val userData: UserData = UserData(
bookmarkedNewsResources = setOf("1", "3"),
followedTopics = emptySet(),
themeBrand = ThemeBrand.ANDROID,
darkThemeConfig = DarkThemeConfig.DARK,
shouldHideOnboarding = true,
useDynamicColor = false,
)
val topics = listOf( object PreviewParameterData {
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 = "",
),
)
return sequenceOf( private val userData: UserData = UserData(
listOf( bookmarkedNewsResources = setOf("1", "3"),
UserNewsResource( followedTopics = emptySet(),
newsResource = NewsResource( themeBrand = ThemeBrand.ANDROID,
id = "1", darkThemeConfig = DarkThemeConfig.DARK,
title = "Android Basics with Compose", shouldHideOnboarding = true,
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. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids 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", useDynamicColor = false,
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( val topics = listOf(
year = 2022, Topic(
monthNumber = 5, id = "2",
dayOfMonth = 4, name = "Headlines",
hour = 23, shortDescription = "News we want everyone to see",
minute = 0, longDescription = "Stay up to date with the latest events and announcements from Android!",
second = 0, 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",
nanosecond = 0, url = "",
).toInstant(TimeZone.UTC), ),
type = NewsResourceType.Codelab, Topic(
topics = listOf(topics[2]), id = "3",
), name = "UI",
userData = userData, 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!",
UserNewsResource( 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",
newsResource = NewsResource( url = "",
id = "2", ),
title = "Thanks for helping us reach 1M YouTube Subscribers", Topic(
content = "Thank you everyone for following the Now in Android series and everything the " + id = "4",
"Android Developers YouTube channel has to offer. During the Android Developer " + name = "Testing",
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " + shortDescription = "CI, Espresso, TestLab, etc",
"thank you all.", 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.",
url = "https://youtu.be/-fJ6poHQrjM", 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",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", url = "",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), ),
type = Video, )
topics = topics.take(2),
), val newsResources = listOf(
userData = userData, UserNewsResource(
), newsResource = NewsResource(
UserNewsResource( id = "1",
newsResource = NewsResource( title = "Android Basics with Compose",
id = "3", 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. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids 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",
title = "Transformations and customisations in the Paging Library", url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
content = "A demonstration of different operations that can be performed " + headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
"with Paging. Transformations like inserting separators, when to " + publishDate = LocalDateTime(
"create a new pager, and customisation options for consuming " + year = 2022,
"PagingData.", monthNumber = 5,
url = "https://youtu.be/ZARz0pjm5YM", dayOfMonth = 4,
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", hour = 23,
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), minute = 0,
type = Video, second = 0,
topics = listOf(topics[2]), nanosecond = 0,
), ).toInstant(TimeZone.UTC),
userData = userData, 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! Heres 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,
),
)
}

@ -35,6 +35,7 @@ fun TopicsTabContent(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true
) { ) {
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
@ -56,8 +57,10 @@ fun TopicsTabContent(
} }
} }
item { if (withBottomSpacer) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
} }
} }

@ -26,3 +26,9 @@ android {
namespace = "com.google.samples.apps.nowinandroid.feature.search" namespace = "com.google.samples.apps.nowinandroid.feature.search"
} }
dependencies {
implementation(project(":feature:foryou"))
implementation(project(":feature:interests"))
implementation(libs.kotlinx.datetime)
}

@ -17,13 +17,24 @@
package com.google.samples.apps.nowinandroid.feature.search package com.google.samples.apps.nowinandroid.feature.search
import androidx.activity.ComponentActivity 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.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onParent 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.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test 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. * UI test for checking the correct behaviour of the Search screen.
@ -33,12 +44,34 @@ class SearchScreenTest {
@get:Rule @get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>() val composeTestRule = createAndroidComposeRule<ComponentActivity>()
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 @Before
fun setup() { fun setup() {
composeTestRule.activity.apply { 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 composeTestRule
.onNodeWithContentDescription(clearSearchText) .onNodeWithContentDescription(clearSearchContentDesc)
// The parent of the IconButton whose contentDescription matches the clearSearchText // The parent of the IconButton whose contentDescription matches the clearSearchText
// should be the TextField for search // should be the TextField for search
.onParent() .onParent()
.assertIsFocused() .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()
}
} }

@ -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<FollowableTopic> = emptyList(),
val newsResources: List<UserNewsResource> = emptyList(),
) : SearchResultUiState {
fun isEmpty(): Boolean = topics.isEmpty() && newsResources.isEmpty()
}
}

@ -16,24 +16,34 @@
package com.google.samples.apps.nowinandroid.feature.search 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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.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.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.R.string
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.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 import com.google.samples.apps.nowinandroid.feature.search.R as searchR
@Composable @Composable
internal fun SearchRoute( internal fun SearchRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackClick: () -> Unit, onBackClick: () -> Unit,
viewModel: SearchViewModel = hiltViewModel(), onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit,
interestsViewModel: InterestsViewModel = hiltViewModel(),
searchViewModel: SearchViewModel = hiltViewModel(),
forYouViewModel: ForYouViewModel = hiltViewModel(),
) { ) {
SearchScreen( SearchScreen(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, 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( internal fun SearchScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackClick: () -> Unit = {}, onBackClick: () -> Unit = {},
onFollowButtonClick: (String, Boolean) -> Unit = {_, _ -> },
onInterestsClick: () -> Unit = {},
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = {_, _ -> },
onSearchQueryChanged: (String) -> Unit = {}, onSearchQueryChanged: (String) -> Unit = {},
onTopicClick: (String) -> Unit = {},
uiState: SearchResultUiState = SearchResultUiState.Loading,
) { ) {
val searchQuery = remember { mutableStateOf("") }
TrackScreenViewEvent(screenName = "Search") TrackScreenViewEvent(screenName = "Search")
Column( Column(modifier = modifier) {
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
SearchToolbar( SearchToolbar(
onBackClick = onBackClick, onBackClick = onBackClick,
onSearchQueryChanged = onSearchQueryChanged, 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)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
} }
} }
@Composable
fun EmptySearchResultBody(
onInterestsClick: () -> Unit = {},
searchQuery: MutableState<String>,
) {
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<FollowableTopic>,
newsResources: List<UserNewsResource>,
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 @Composable
private fun SearchToolbar( private fun SearchToolbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackClick: () -> Unit = {}, onBackClick: () -> Unit = {},
onSearchQueryChanged: (String) -> Unit = {}, onSearchQueryChanged: (String) -> Unit = {},
searchQuery: MutableState<String> = mutableStateOf(""),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -103,14 +271,19 @@ private fun SearchToolbar(
), ),
) )
} }
SearchTextField(onSearchQueryChanged = onSearchQueryChanged) SearchTextField(
onSearchQueryChanged = onSearchQueryChanged,
searchQuery = searchQuery,
)
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) { private fun SearchTextField(
val textState = remember { mutableStateOf("") } onSearchQueryChanged: (String) -> Unit,
searchQuery: MutableState<String>,
) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
TextField( TextField(
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(
@ -128,26 +301,26 @@ private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) {
) )
}, },
trailingIcon = { trailingIcon = {
IconButton(onClick = { textState.value = "" }) { IconButton(onClick = { searchQuery.value = "" }) {
Icon( Icon(
imageVector = NiaIcons.Close, imageVector = NiaIcons.Close,
contentDescription = stringResource( contentDescription = stringResource(
id = searchR.string.clear_search_text, id = searchR.string.clear_search_text_content_desc,
), ),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
}, },
onValueChange = { onValueChange = {
textState.value = it searchQuery.value = it
onSearchQueryChanged(it) onSearchQueryChanged(it)
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp) .padding(16.dp)
.focusRequester(focusRequester), .focusRequester(focusRequester),
shape = RoundedCornerShape(32.dp), shape = RoundedCornerShape(32.dp),
value = textState.value, value = searchQuery.value,
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
@ -162,10 +335,22 @@ private fun SearchToolbarPreview() {
} }
} }
@Preview
@Composable
private fun EmptySearchResultColumnPreview() {
NiaTheme {
val searchQuery = remember { mutableStateOf("C++") }
EmptySearchResultBody(searchQuery = searchQuery)
}
}
@DevicePreviews @DevicePreviews
@Composable @Composable
private fun SearchScreenPreview() { private fun SearchScreenPreview(
@PreviewParameter(SearchResultUiStatePreviewParameterProvider::class)
searchResultUiState: SearchResultUiState,
) {
NiaTheme { NiaTheme {
SearchScreen() SearchScreen(uiState = searchResultUiState)
} }
} }

@ -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<SearchResultUiState> {
override val values: Sequence<SearchResultUiState> = sequenceOf(SearchResultUiState.Success(
topics = topics.mapIndexed { i, topic ->
FollowableTopic(topic = topic, isFollowed = i % 2 == 0)
},
newsResources = newsResources,
))
}

@ -28,10 +28,18 @@ fun NavController.navigateToSearch(navOptions: NavOptions? = null) {
this.navigate(searchRoute, navOptions) 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 // TODO: Handle back stack for each top-level destination. At the moment each top-level
// destination may have own search screen's back stack. // destination may have own search screen's back stack.
composable(route = searchRoute) { composable(route = searchRoute) {
SearchRoute(onBackClick = onBackClick) SearchRoute(
onBackClick = onBackClick,
onInterestsClick = onInterestsClick,
onTopicClick = onTopicClick
)
} }
} }

@ -16,5 +16,11 @@
--> -->
<resources> <resources>
<string name="search">Search</string> <string name="search">Search</string>
<string name="clear_search_text">Clear search text</string> <string name="clear_search_text_content_desc">Clear search text</string>
<string name="search_result_not_found">Sorry, there is no content found for your search \"%1$s\"</string>
<string name="try_another_search">Try another search or explorer </string>
<string name="interests">Interests</string>
<string name="to_browse_topics"> to browse topics</string>
<string name="topics">Topics</string>
<string name="updates">Updates</string>
</resources> </resources>
Loading…
Cancel
Save