Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 2acdbce9 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

Merge branch '3665-refactor-search-ui' into 'main'

refactor: introduce SearchViewHandler to delegate UI logic from SearchFragment

See merge request !594
parents 022a713b 345603a2
Loading
Loading
Loading
Loading
Loading
+46 −81
Original line number Diff line number Diff line
@@ -21,18 +21,13 @@ package foundation.e.apps.ui.search

import android.app.Activity
import android.content.Context
import android.database.MatrixCursor
import android.os.Bundle
import android.provider.BaseColumns
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.appcompat.widget.SearchView
import androidx.cursoradapter.widget.CursorAdapter
import androidx.cursoradapter.widget.SimpleCursorAdapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
@@ -48,7 +43,6 @@ import dagger.hilt.android.AndroidEntryPoint
import foundation.e.apps.R
import foundation.e.apps.data.application.ApplicationInstaller
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.search.SearchSuggestion
import foundation.e.apps.data.enums.Status
import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.databinding.FragmentSearchBinding
@@ -66,13 +60,9 @@ import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject

@Suppress("TooManyFunctions", "MaxLineLength") // TODO: Remove after refactoring is complete
@AndroidEntryPoint
class SearchFragment :
    Fragment(R.layout.fragment_search),
    SearchView.OnQueryTextListener,
    SearchView.OnSuggestionListener,
    ApplicationInstaller {
class SearchFragment : Fragment(R.layout.fragment_search), ApplicationInstaller,
    SearchViewHandler.SearchViewListener {

    @Inject
    lateinit var pwaManager: PwaManager
@@ -86,10 +76,11 @@ class SearchFragment :
    val mainActivityViewModel: MainActivityViewModel by activityViewModels()
    private val appProgressViewModel: AppProgressViewModel by viewModels()

    private val SUGGESTION_KEY = "suggestion"
    private var lastSearch = ""

    private var searchView: SearchView? = null
    private var searchViewHandler: SearchViewHandler? = null

    private var shimmerLayout: ShimmerFrameLayout? = null
    private var recyclerView: RecyclerView? = null
    private var searchHintLayout: LinearLayout? = null
@@ -111,8 +102,8 @@ class SearchFragment :
        filterChipOpenSource = binding.filterChipOpenSource
        filterChipPWA = binding.filterChipPWA

        setupSearchView()
        setupSearchViewSuggestions()
        setupSearchViewHandler()
        setHasOptionsMenu(true)

        // Setup Search Results
        val listAdapter = setupSearchResult(view)
@@ -261,19 +252,26 @@ class SearchFragment :
        return listAdapter
    }

    private fun setupSearchViewSuggestions() {
        val from = arrayOf(SUGGESTION_KEY)
        val to = intArrayOf(android.R.id.text1)
        searchView?.suggestionsAdapter = SimpleCursorAdapter(
            context,
            R.layout.custom_simple_list_item, null, from, to,
            CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER
    private fun setupSearchViewHandler() {
        searchView?.let {
            searchViewHandler = SearchViewHandler(
                searchView = it,
                context = requireContext(),
                listener = this,
            )
            searchViewHandler?.setup()
        }

        if (searchViewModel.searchText.isNotBlank()) {
            searchView?.setQuery(
                searchViewModel.searchText, false
            ) // 'false' prevents re-submitting the query
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                searchViewModel.searchSuggestions.collectLatest {
                    populateSuggestionsAdapter(it)
                searchViewModel.searchSuggestions.collectLatest { suggestions ->
                    searchViewHandler?.populateSuggestionsAdapter(suggestions)
                }
            }
        }
@@ -297,13 +295,6 @@ class SearchFragment :
        filterChipPWA.setOnCheckedChangeListener(listener)
    }

    private fun setupSearchView() {
        setHasOptionsMenu(true)
        searchView?.setOnSuggestionListener(this)
        searchView?.setOnQueryTextListener(this)
        searchView?.let { configureCloseButton(it) }
    }

    private fun showPaidAppMessage(application: Application) {
        ApplicationDialogFragment(
            title = getString(R.string.dialog_title_paid_app, application.name),
@@ -388,7 +379,9 @@ class SearchFragment :
            }
        }

        if (searchViewModel.searchText.isEmpty() && (recyclerView?.adapter as ApplicationListRVAdapter).currentList.isEmpty()) {
        if (searchViewModel.searchText.isEmpty() &&
            (recyclerView?.adapter as ApplicationListRVAdapter).currentList.isEmpty()
        ) {
            searchView?.requestFocus()
            showKeyboard()
        }
@@ -414,41 +407,6 @@ class SearchFragment :
        super.onPause()
    }

    override fun onQueryTextSubmit(query: String?): Boolean {
        query?.let { text ->
            if (text.isNotEmpty()) {
                hideKeyboard(activity as Activity)
            }

            view?.requestFocus()
            searchHintLayout?.visibility = View.GONE
            /*
             * Set the search text and call for network result.
             */
            searchViewModel.searchText = text
            initiateSearch()
        }
        return false
    }

    override fun onQueryTextChange(newText: String?): Boolean {
        searchViewModel.onQueryChanged(newText.orEmpty())
        return true
    }

    override fun onSuggestionSelect(position: Int): Boolean {
        return true
    }

    override fun onSuggestionClick(position: Int): Boolean {
        searchViewModel.searchSuggestions.value.let {
            if (it.isNotEmpty()) {
                searchView?.setQuery(it[position].suggestion, true)
            }
        }
        return true
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
@@ -458,13 +416,7 @@ class SearchFragment :
        recyclerView = null
        searchHintLayout = null
        noAppsFoundLayout = null
    }

    private fun configureCloseButton(searchView: SearchView) {
        val searchClose = searchView.javaClass.getDeclaredField("mCloseButton")
        searchClose.isAccessible = true
        val closeImage = searchClose.get(searchView) as ImageView
        closeImage.setImageResource(R.drawable.ic_close)
        searchViewHandler = null
    }

    private fun hideKeyboard(activity: Activity) {
@@ -488,14 +440,27 @@ class SearchFragment :
        }
    }

    private fun populateSuggestionsAdapter(suggestions: List<SearchSuggestion>?) {
        val cursor = MatrixCursor(arrayOf(BaseColumns._ID, SUGGESTION_KEY))
        suggestions?.let {
            for (i in it.indices) {
                cursor.addRow(arrayOf(i, it[i].suggestion))
    override fun onQuerySubmitted(query: String) {
        query.let { text ->
            if (text.isNotEmpty()) {
                hideKeyboard(activity as Activity)
            }

            view?.requestFocus()
            searchHintLayout?.visibility = View.GONE

            searchViewModel.searchText = text
            initiateSearch()
        }
    }
        searchView?.suggestionsAdapter?.changeCursor(cursor)

    override fun onQueryChanged(newText: String) {
        searchViewModel.onSearchQueryChanged(newText)
    }

    override fun onSuggestionClicked(position: Int) {
        val suggestion = searchViewModel.searchSuggestions.value.getOrNull(position)?.suggestion
        suggestion?.let { searchView?.setQuery(it, true) }
    }

    override fun installApplication(app: Application) {
+100 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps.ui.search

import android.content.Context
import android.database.MatrixCursor
import android.provider.BaseColumns
import android.widget.ImageView
import androidx.appcompat.widget.SearchView
import androidx.cursoradapter.widget.CursorAdapter
import androidx.cursoradapter.widget.SimpleCursorAdapter
import foundation.e.apps.R
import foundation.e.apps.data.application.search.SearchSuggestion

class SearchViewHandler(
    private val searchView: SearchView,
    private val context: Context,
    private val listener: SearchViewListener,
) : SearchView.OnQueryTextListener, SearchView.OnSuggestionListener {

    interface SearchViewListener {
        fun onQuerySubmitted(query: String)
        fun onQueryChanged(newText: String)
        fun onSuggestionClicked(position: Int)
    }

    fun setup() {
        searchView.setOnQueryTextListener(this)
        searchView.setOnSuggestionListener(this)
        setupSuggestions()
        configureCloseButton()
    }

    private fun setupSuggestions() {
        val from = arrayOf(SUGGESTION_KEY)
        val to = intArrayOf(android.R.id.text1)
        searchView.suggestionsAdapter = SimpleCursorAdapter(
            context,
            R.layout.custom_simple_list_item, null, from, to,
            CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER
        )
    }

    internal fun populateSuggestionsAdapter(suggestions: List<SearchSuggestion>?) {
        val cursor = MatrixCursor(arrayOf(BaseColumns._ID, SUGGESTION_KEY))
        suggestions?.let {
            for (i in it.indices) {
                cursor.addRow(arrayOf(i, it[i].suggestion))
            }
        }
        searchView.suggestionsAdapter?.changeCursor(cursor)
    }

    private fun configureCloseButton() {
        val searchClose = searchView.javaClass.getDeclaredField("mCloseButton")
        searchClose.isAccessible = true
        val closeImage = searchClose.get(searchView) as ImageView
        closeImage.setImageResource(R.drawable.ic_close)
    }

    override fun onQueryTextSubmit(query: String?): Boolean {
        query?.let { text ->
            listener.onQuerySubmitted(text)
        }
        return false
    }

    override fun onQueryTextChange(newText: String?): Boolean {
        listener.onQueryChanged(newText.orEmpty())
        return true
    }

    override fun onSuggestionSelect(position: Int): Boolean {
        return true
    }

    override fun onSuggestionClick(position: Int): Boolean {
        listener.onSuggestionClicked(position)
        return true
    }

    companion object {
        private const val SUGGESTION_KEY = "suggestion"
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -86,7 +86,7 @@ class SearchViewModel @Inject constructor(

    val query = MutableStateFlow("")

    fun onQueryChanged(value: String) {
    fun onSearchQueryChanged(value: String) {
        query.value = value
    }