Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ALTAPPS-1059: Android topics search #789

Merged
merged 17 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -68,6 +68,7 @@ class GamificationToolbarDelegate(
viewBinding.gamificationTrackProgressTextView.text = progress.formattedValue
}
}
viewBinding.gamificationSearchButton.isVisible = true
}
}

Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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) {
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()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
}
Loading