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 1 year 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.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)

@ -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

@ -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<List<UserNewsResource>> {
override val values: Sequence<List<UserNewsResource>>
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<List<UserNewsResource>> = 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. 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",
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! 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,
),
),
)
}
}
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. 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",
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! 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,
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))
}
}
}
}

@ -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)
}

@ -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<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
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()
}
}

@ -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
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<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
private fun SearchToolbar(
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onSearchQueryChanged: (String) -> Unit = {},
searchQuery: MutableState<String> = 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<String>,
) {
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)
}
}

@ -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)
}
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
)
}
}

@ -16,5 +16,11 @@
-->
<resources>
<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>
Loading…
Cancel
Save