diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt index c4dc79a9f4..f99a159c5c 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt @@ -12,31 +12,31 @@ import org.hyperskill.app.android.main.view.ui.navigation.MainScreenRouter import org.hyperskill.app.android.main.view.ui.navigation.Tabs import org.hyperskill.app.android.main.view.ui.navigation.switch import org.hyperskill.app.android.progress.navigation.ProgressScreen +import org.hyperskill.app.android.topic_search.navigation.TopicSearchScreen import org.hyperskill.app.android.view.base.ui.extension.setElevationOnCollapsed import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature +import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature.Message import ru.nobird.android.view.base.ui.extension.setTextIfChanged class GamificationToolbarDelegate( lifecycleOwner: LifecycleOwner, private val context: Context, private val viewBinding: LayoutGamificationToolbarBinding, - onNewMessage: (GamificationToolbarFeature.Message) -> Unit + onNewMessage: (Message) -> Unit ) { init { with(viewBinding) { gamificationAppBar.setElevationOnCollapsed(lifecycleOwner.lifecycle) gamificationAppBar.setExpanded(true) - gamificationStreakDurationTextView.setOnClickListener { - onNewMessage( - GamificationToolbarFeature.Message.ClickedStreak - ) + onNewMessage(Message.ClickedStreak) } gamificationTrackProgressLinearLayout.setOnClickListener { - onNewMessage( - GamificationToolbarFeature.Message.ClickedProgress - ) + onNewMessage(Message.ClickedProgress) + } + gamificationSearchButton.setOnClickListener { + onNewMessage(Message.ClickedSearch) } } } @@ -68,6 +68,7 @@ class GamificationToolbarDelegate( viewBinding.gamificationTrackProgressTextView.text = progress.formattedValue } } + viewBinding.gamificationSearchButton.isVisible = true } } @@ -82,7 +83,7 @@ class GamificationToolbarDelegate( GamificationToolbarFeature.Action.ViewAction.ShowProgressScreen -> router.navigateTo(ProgressScreen) GamificationToolbarFeature.Action.ViewAction.ShowSearchScreen -> { - // TODO: ALTAPPS-1059 Show search screen + router.navigateTo(TopicSearchScreen) } } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/fragment/TopicSearchFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/fragment/TopicSearchFragment.kt new file mode 100644 index 0000000000..b7500f1b13 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/fragment/TopicSearchFragment.kt @@ -0,0 +1,146 @@ +package org.hyperskill.app.android.topic_search.fragment + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.Toast +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import by.kirich1409.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.hyperskill.app.android.HyperskillApp +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.view.ui.navigation.requireRouter +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.databinding.FragmentSearchTopicBinding +import org.hyperskill.app.android.step.view.screen.StepScreen +import org.hyperskill.app.android.topic_search.ui.TopicSearchResult +import org.hyperskill.app.core.view.handleActions +import org.hyperskill.app.search.presentation.SearchFeature +import org.hyperskill.app.search.presentation.SearchViewModel +import ru.nobird.android.view.base.ui.extension.setTextIfChanged + +class TopicSearchFragment : Fragment(R.layout.fragment_search_topic) { + + companion object { + private const val TALKBACK_FOCUS_CHANGE_DELAY_MS: Long = 100 + + fun newInstance(): TopicSearchFragment = + TopicSearchFragment() + } + + private var viewModelFactory: ViewModelProvider.Factory? = null + private val searchViewModel: SearchViewModel by viewModels { requireNotNull(viewModelFactory) } + + private val viewBinding: FragmentSearchTopicBinding by viewBinding(FragmentSearchTopicBinding::bind) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + injectComponent() + searchViewModel.handleActions(this, ::handleAction) + } + + private fun injectComponent() { + viewModelFactory = + HyperskillApp.graph().buildPlatformSearchComponent().reduxViewModelFactory + } + + override fun onResume() { + super.onResume() + searchViewModel.onNewMessage( + SearchFeature.Message.ViewedEventMessage + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupToolbar() + viewBinding.topicSearchComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) + setContent { + HyperskillTheme { + TopicSearchResult(viewModel = searchViewModel) + } + } + } + } + + private fun setupToolbar() { + with(viewBinding) { + topicSearchToolbar.setNavigationOnClickListener { + requireRouter().exit() + } + topicSearchClearButton.isVisible = topicSearchEditText.text.isNotEmpty() + topicSearchClearButton.setOnClickListener { + topicSearchEditText.text.clear() + } + topicSearchEditText.doAfterTextChanged { text -> + topicSearchClearButton.isVisible = !text.isNullOrEmpty() + searchViewModel.onNewMessage( + SearchFeature.Message.QueryChanged(text.toString()) + ) + } + topicSearchEditText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + searchViewModel.onNewMessage( + SearchFeature.Message.SearchClicked + ) + } + false + } + requestFocusAndShowKeyboard(topicSearchEditText) + } + setupEditTextRendering() + } + + private fun requestFocusAndShowKeyboard( + editText: EditText, + ) { + // Without a delay requesting focus on edit text fails when talkback is active. + editText.postDelayed( + { + editText.requestFocus() + val inputMethodManager = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + }, + TALKBACK_FOCUS_CHANGE_DELAY_MS + ) + } + + private fun setupEditTextRendering() { + searchViewModel.state + .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .map { it.query } + .onEach { query -> + viewBinding.topicSearchEditText.setTextIfChanged(query) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun handleAction(action: SearchFeature.Action.ViewAction) { + when (action) { + is SearchFeature.Action.ViewAction.OpenStepScreen -> { + requireRouter().navigateTo(StepScreen(action.stepRoute)) + } + is SearchFeature.Action.ViewAction.OpenStepScreenFailed -> { + Toast.makeText( + requireContext(), + org.hyperskill.app.R.string.search_open_step_screen_error_message, + Toast.LENGTH_SHORT + ).show() + } + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/navigation/TopicSearchScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/navigation/TopicSearchScreen.kt new file mode 100644 index 0000000000..fd5eb2f5a0 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/navigation/TopicSearchScreen.kt @@ -0,0 +1,11 @@ +package org.hyperskill.app.android.topic_search.navigation + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import com.github.terrakok.cicerone.androidx.FragmentScreen +import org.hyperskill.app.android.topic_search.fragment.TopicSearchFragment + +object TopicSearchScreen : FragmentScreen { + override fun createFragment(factory: FragmentFactory): Fragment = + TopicSearchFragment.newInstance() +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchEmptyResult.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchEmptyResult.kt new file mode 100644 index 0000000000..aebaf768bd --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchEmptyResult.kt @@ -0,0 +1,49 @@ +package org.hyperskill.app.android.topic_search.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme + +@Composable +fun TopicSearchEmptyResult( + modifier: Modifier = Modifier +) { + Box(modifier) { + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(id = R.string.search_placeholder_empty_title), + style = MaterialTheme.typography.h6, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Text( + text = stringResource(id = R.string.search_placeholder_empty_subtitle), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } +} + +@Preview +@Composable +private fun TopicSearchEmptyResultPreview() { + HyperskillTheme { + TopicSearchEmptyResult() + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResult.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResult.kt new file mode 100644 index 0000000000..68d617e4a1 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResult.kt @@ -0,0 +1,75 @@ +package org.hyperskill.app.android.topic_search.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.ScreenDataLoadingError +import org.hyperskill.app.search.presentation.SearchFeature.Message +import org.hyperskill.app.search.presentation.SearchFeature.SearchResultsViewState +import org.hyperskill.app.search.presentation.SearchViewModel + +@Composable +fun TopicSearchResult(viewModel: SearchViewModel) { + val viewState: SearchResultsViewState by viewModel + .state + .map { it.searchResultsViewState } + .collectAsStateWithLifecycle( + initialValue = SearchResultsViewState.Idle, + lifecycle = LocalLifecycleOwner.current.lifecycle + ) + TopicSearchResult( + viewState = viewState, + onNewMessage = viewModel::onNewMessage + ) +} + +@Composable +fun TopicSearchResult( + viewState: SearchResultsViewState, + onNewMessage: (Message) -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.fillMaxSize()) { + when (viewState) { + SearchResultsViewState.Idle -> { + // no op + } + SearchResultsViewState.Empty -> { + TopicSearchEmptyResult( + modifier = Modifier.fillMaxSize() + ) + } + SearchResultsViewState.Loading -> { + TopicSearchSkeleton(modifier = Modifier.fillMaxSize()) + } + SearchResultsViewState.Error -> { + ScreenDataLoadingError( + errorMessage = stringResource(id = R.string.search_placeholder_error_description) + ) { + onNewMessage(Message.RetrySearchClicked) + } + } + is SearchResultsViewState.Content -> { + val onItemClick = remember { + { id: Long -> + onNewMessage( + Message.SearchResultsItemClicked(id) + ) + } + } + TopicSearchResultContent( + items = viewState.searchResults, + onItemClick = onItemClick + ) + } + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResultContent.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResultContent.kt new file mode 100644 index 0000000000..e2a6d5a5d0 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResultContent.kt @@ -0,0 +1,116 @@ +package org.hyperskill.app.android.topic_search.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.search.presentation.SearchFeature.SearchResultsViewState.Content.Item + +@Composable +fun TopicSearchResultContent( + items: List, + onItemClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + content = { + itemsIndexed( + items = items, + key = { _, item -> item.id } + ) { index, item -> + TopicSearchResultItem( + title = item.title, + onClick = remember { + { + onItemClick(item.id) + } + }, + modifier = Modifier + .fillParentMaxWidth() + .padding( + top = TopicSearchResultDefaults.resultItemsVerticalSpacing, + bottom = if (index == items.lastIndex) { + TopicSearchResultDefaults.resultItemsVerticalSpacing + } else { + 0.dp + }, + start = TopicSearchResultDefaults.horizontalPadding, + end = TopicSearchResultDefaults.horizontalPadding + ) + ) + } + }, + modifier = modifier + ) +} + +@Composable +fun TopicSearchResultItem( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val currentOnClick by rememberUpdatedState(newValue = onClick) + val cornerRadius = dimensionResource(id = R.dimen.corner_radius) + Box( + modifier + .clip(RoundedCornerShape(cornerRadius)) + .border( + width = 1.dp, + color = colorResource(id = org.hyperskill.app.R.color.color_on_surface_alpha_12), + shape = RoundedCornerShape(cornerRadius) + ) + .clickable(onClick = currentOnClick) + .padding(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.body2, + color = colorResource(id = org.hyperskill.app.R.color.color_on_surface_alpha_60) + ) + } +} + +@Preview +@Composable +private fun TopicSearchResultItemPreview() { + HyperskillTheme { + TopicSearchResultItem( + title = "Basic data types", + onClick = {} + ) + } +} + +@Preview +@Composable +private fun TopicSearchResultContentPreview() { + HyperskillTheme { + TopicSearchResultContent( + items = List(3) { + Item( + id = it.toLong(), + title = "Basic data types" + ) + }, + onItemClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResultDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResultDefaults.kt new file mode 100644 index 0000000000..c81617ed3d --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchResultDefaults.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.android.topic_search.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +object TopicSearchResultDefaults { + val horizontalPadding: Dp = 20.dp + val resultItemsVerticalSpacing: Dp = 10.dp +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchSkeleton.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchSkeleton.kt new file mode 100644 index 0000000000..0977b9d749 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topic_search/ui/TopicSearchSkeleton.kt @@ -0,0 +1,46 @@ +package org.hyperskill.app.android.topic_search.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.core.view.ui.widget.compose.ShimmerLoading + +@Composable +fun TopicSearchSkeleton( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .padding(top = TopicSearchResultDefaults.resultItemsVerticalSpacing), + verticalArrangement = Arrangement.spacedBy(TopicSearchResultDefaults.resultItemsVerticalSpacing) + ) { + repeat(5) { + TopicSearchSkeletonItem( + modifier = Modifier.padding(horizontal = TopicSearchResultDefaults.horizontalPadding) + ) + } + } +} + +@Composable +fun TopicSearchSkeletonItem( + modifier: Modifier = Modifier +) { + ShimmerLoading( + modifier = modifier.requiredHeight(50.dp) + ) +} + +@Preview +@Composable +fun TopicSearchSkeletonPreview() { + HyperskillTheme { + TopicSearchSkeleton() + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/drawable/ic_menu_search.xml b/androidHyperskillApp/src/main/res/drawable/ic_menu_search.xml new file mode 100644 index 0000000000..fd5ebb824b --- /dev/null +++ b/androidHyperskillApp/src/main/res/drawable/ic_menu_search.xml @@ -0,0 +1,13 @@ + + + + diff --git a/androidHyperskillApp/src/main/res/layout/fragment_search_topic.xml b/androidHyperskillApp/src/main/res/layout/fragment_search_topic.xml new file mode 100644 index 0000000000..a71fa86384 --- /dev/null +++ b/androidHyperskillApp/src/main/res/layout/fragment_search_topic.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/layout_gamification_toolbar.xml b/androidHyperskillApp/src/main/res/layout/layout_gamification_toolbar.xml index 829a27af35..bcd674cbc3 100644 --- a/androidHyperskillApp/src/main/res/layout/layout_gamification_toolbar.xml +++ b/androidHyperskillApp/src/main/res/layout/layout_gamification_toolbar.xml @@ -47,7 +47,8 @@ android:focusable="true" android:background="?attr/selectableItemBackgroundBorderless" android:visibility="gone" - tools:visibility="visible"> + tools:visibility="visible" + android:layout_marginEnd="8dp"> + + @color/color_background @anim/slide_in_from_bottom @anim/slide_out_to_bottom + adjustResize