From d730235287dccf53d72f5933129383a86a004fdf Mon Sep 17 00:00:00 2001 From: Takeshi Hagikura Date: Tue, 11 Apr 2023 23:53:26 +0900 Subject: [PATCH] Search UI skeleton (#651) * Add UI skeleton for the search feature This CL adds an UI skeleton of the search feature including: - The search navigation icon in the NiaTopAppBar - The text field to enter the search query - Almost empty ViewModel --- app/build.gradle.kts | 1 + .../nowinandroid/navigation/NiaNavHost.kt | 2 + .../samples/apps/nowinandroid/ui/NiaApp.kt | 5 + .../apps/nowinandroid/ui/NiaAppState.kt | 5 + feature/search/.gitignore | 1 + feature/search/build.gradle.kts | 28 +++ .../feature/search/SearchScreenTest.kt | 58 ++++++ feature/search/src/main/AndroidManifest.xml | 17 ++ .../feature/search/SearchScreen.kt | 171 ++++++++++++++++++ .../feature/search/SearchViewModel.kt | 30 +++ .../search/navigation/SearchNavigation.kt | 37 ++++ .../search/src/main/res/values/strings.xml | 20 ++ .../settings/src/main/res/values/strings.xml | 1 + settings.gradle.kts | 1 + 14 files changed, 377 insertions(+) create mode 100644 feature/search/.gitignore create mode 100644 feature/search/build.gradle.kts create mode 100644 feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt create mode 100644 feature/search/src/main/AndroidManifest.xml create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt create mode 100644 feature/search/src/main/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 81c128b91..42dee2602 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,7 @@ dependencies { implementation(project(":feature:foryou")) implementation(project(":feature:bookmarks")) implementation(project(":feature:topic")) + implementation(project(":feature:search")) implementation(project(":feature:settings")) implementation(project(":core:common")) 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 bc950ee92..8e4caabe8 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,6 +24,7 @@ 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.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen @@ -48,6 +49,7 @@ fun NiaNavHost( // TODO: handle topic clicks from each top level destination forYouScreen(onTopicClick = {}) bookmarksScreen(onTopicClick = {}) + searchScreen(onBackClick = navController::popBackStack) 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 780849cf2..1aa5a3440 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 @@ -173,6 +173,10 @@ fun NiaApp( if (destination != null) { NiaTopAppBar( titleRes = destination.titleTextId, + navigationIcon = NiaIcons.Search, + navigationIconContentDescription = stringResource( + id = settingsR.string.top_app_bar_navigation_icon_description, + ), actionIcon = NiaIcons.Settings, actionIconContentDescription = stringResource( id = settingsR.string.top_app_bar_action_icon_description, @@ -181,6 +185,7 @@ fun NiaApp( containerColor = Color.Transparent, ), onActionClick = { appState.setShowSettingsDialog(true) }, + onNavigationClick = { appState.navigateToSearch() }, ) } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index df6fe1da2..fb6ae1bc6 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -42,6 +42,7 @@ import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavi import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph +import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU @@ -172,6 +173,10 @@ class NiaAppState( fun setShowSettingsDialog(shouldShow: Boolean) { shouldShowSettingsDialog = shouldShow } + + fun navigateToSearch() { + navController.navigateToSearch() + } } /** diff --git a/feature/search/.gitignore b/feature/search/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts new file mode 100644 index 000000000..1b6ae0f9c --- /dev/null +++ b/feature/search/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +import com.android.build.api.dsl.ManagedVirtualDevice + +plugins { + id("nowinandroid.android.feature") + id("nowinandroid.android.library.compose") + id("nowinandroid.android.library.jacoco") +} + +android { + namespace = "com.google.samples.apps.nowinandroid.feature.search" +} + 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 new file mode 100644 index 000000000..37fed8f85 --- /dev/null +++ b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -0,0 +1,58 @@ +/* + * 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.activity.ComponentActivity +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onParent +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * UI test for checking the correct behaviour of the Search screen. + */ +class SearchScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private lateinit var clearSearchText: String + + @Before + fun setup() { + composeTestRule.activity.apply { + clearSearchText = getString(R.string.clear_search_text) + } + } + + @Test + fun searchTextField_isFocused() { + composeTestRule.setContent { + SearchScreen() + } + + composeTestRule + .onNodeWithContentDescription(clearSearchText) + // The parent of the IconButton whose contentDescription matches the clearSearchText + // should be the TextField for search + .onParent() + .assertIsFocused() + } +} diff --git a/feature/search/src/main/AndroidManifest.xml b/feature/search/src/main/AndroidManifest.xml new file mode 100644 index 000000000..70c188dd8 --- /dev/null +++ b/feature/search/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + 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 new file mode 100644 index 000000000..206aa609c --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -0,0 +1,171 @@ +/* + * 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.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +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.ui.DevicePreviews +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.feature.search.R as searchR + +@Composable +internal fun SearchRoute( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, + viewModel: SearchViewModel = hiltViewModel(), +) { + SearchScreen( + modifier = modifier, + onBackClick = onBackClick, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + ) +} + +@Composable +internal fun SearchScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onSearchQueryChanged: (String) -> Unit = {}, +) { + TrackScreenViewEvent(screenName = "Search") + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + SearchToolbar( + onBackClick = onBackClick, + onSearchQueryChanged = onSearchQueryChanged, + ) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } +} + +@Composable +private fun SearchToolbar( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onSearchQueryChanged: (String) -> Unit = {}, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth(), + ) { + IconButton(onClick = { onBackClick() }) { + Icon( + imageVector = NiaIcons.ArrowBack, + contentDescription = stringResource( + id = string.back, + ), + ) + } + SearchTextField(onSearchQueryChanged = onSearchQueryChanged) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) { + val textState = remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + TextField( + colors = TextFieldDefaults.textFieldColors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + leadingIcon = { + Icon( + imageVector = NiaIcons.Search, + contentDescription = stringResource( + id = searchR.string.search, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + trailingIcon = { + IconButton(onClick = { textState.value = "" }) { + Icon( + imageVector = NiaIcons.Close, + contentDescription = stringResource( + id = searchR.string.clear_search_text, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + onValueChange = { + textState.value = it + onSearchQueryChanged(it) + }, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .focusRequester(focusRequester), + shape = RoundedCornerShape(32.dp), + value = textState.value, + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Preview +@Composable +private fun SearchToolbarPreview() { + NiaTheme { + SearchToolbar() + } +} + +@DevicePreviews +@Composable +private fun SearchScreenPreview() { + NiaTheme { + SearchScreen() + } +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt new file mode 100644 index 000000000..00194af0a --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt @@ -0,0 +1,30 @@ +/* + * 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.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor() : ViewModel() { + + fun onSearchQueryChanged(searchQuery: String) { + // TODO: Pass it to UseCase + println("New search query is '$searchQuery'") + } +} 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 new file mode 100644 index 000000000..5122ce0c7 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt @@ -0,0 +1,37 @@ +/* + * 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.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.google.samples.apps.nowinandroid.feature.search.SearchRoute + +const val searchRoute = "search_route" + +fun NavController.navigateToSearch(navOptions: NavOptions? = null) { + this.navigate(searchRoute, navOptions) +} + +fun NavGraphBuilder.searchScreen(onBackClick: () -> 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) + } +} diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml new file mode 100644 index 000000000..e97f97089 --- /dev/null +++ b/feature/search/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + Search + Clear search text + \ No newline at end of file diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 5efaeb577..cbd4df8ed 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ --> Settings + Search Settings Loading... Privacy policy diff --git a/settings.gradle.kts b/settings.gradle.kts index 2af582a7b..d0c477b3d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -53,6 +53,7 @@ include(":feature:foryou") include(":feature:interests") include(":feature:bookmarks") include(":feature:topic") +include(":feature:search") include(":feature:settings") include(":lint") include(":sync:work")