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