Merge branch 'migration/gplayapi-3.5.4' into 'dev'
gplayapi:3.5.5 See merge request AuroraOSS/AuroraStore!487
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* Aurora Store
|
||||||
|
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
|
||||||
|
*
|
||||||
|
* Aurora Store 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 2 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Aurora Store 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 Aurora Store. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.view.epoxy.controller
|
||||||
|
|
||||||
|
import com.airbnb.epoxy.TypedEpoxyController
|
||||||
|
import com.aurora.gplayapi.data.models.StreamBundle
|
||||||
|
import com.aurora.store.R
|
||||||
|
import com.aurora.store.view.epoxy.controller.GenericCarouselController.Callbacks
|
||||||
|
import com.aurora.store.view.epoxy.groups.CarouselModelGroup
|
||||||
|
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
|
||||||
|
import com.aurora.store.view.epoxy.views.app.NoAppViewModel_
|
||||||
|
import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_
|
||||||
|
|
||||||
|
open class SearchCarouselController(private val callbacks: Callbacks) :
|
||||||
|
|
||||||
|
TypedEpoxyController<StreamBundle?>() {
|
||||||
|
|
||||||
|
override fun buildModels(streamBundle: StreamBundle?) {
|
||||||
|
setFilterDuplicates(true)
|
||||||
|
if (streamBundle == null) {
|
||||||
|
for (i in 1..6) {
|
||||||
|
add(
|
||||||
|
AppListViewShimmerModel_()
|
||||||
|
.id(i)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (streamBundle.streamClusters.isEmpty()) {
|
||||||
|
add(
|
||||||
|
NoAppViewModel_()
|
||||||
|
.id("no_app")
|
||||||
|
.icon(R.drawable.ic_apps)
|
||||||
|
.message(R.string.no_apps_available)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
streamBundle.streamClusters.values
|
||||||
|
.filter { it.clusterAppList.isNotEmpty() } // Filter out empty clusters, mostly related keywords
|
||||||
|
.forEach {
|
||||||
|
if (it.clusterTitle.isEmpty() or (it.clusterTitle == streamBundle.streamTitle)) {
|
||||||
|
if (it.clusterAppList.isNotEmpty()) {
|
||||||
|
it.clusterAppList.forEach { app ->
|
||||||
|
add(
|
||||||
|
AppListViewModel_()
|
||||||
|
.id(app.id)
|
||||||
|
.app(app)
|
||||||
|
.click { _ -> callbacks.onAppClick(app) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add(CarouselModelGroup(it, callbacks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamBundle.hasNext())
|
||||||
|
add(
|
||||||
|
AppListViewShimmerModel_()
|
||||||
|
.id("progress")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,68 +24,84 @@ import android.view.View
|
|||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import com.aurora.gplayapi.data.models.SearchBundle
|
import com.aurora.gplayapi.data.models.App
|
||||||
|
import com.aurora.gplayapi.data.models.StreamCluster
|
||||||
|
import com.aurora.store.AppStreamStash
|
||||||
|
import com.aurora.store.data.model.ViewState
|
||||||
|
import com.aurora.store.data.model.ViewState.Loading.getDataAs
|
||||||
import com.aurora.store.databinding.FragmentGenericWithToolbarBinding
|
import com.aurora.store.databinding.FragmentGenericWithToolbarBinding
|
||||||
import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener
|
import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener
|
||||||
import com.aurora.store.view.epoxy.views.AppProgressViewModel_
|
import com.aurora.store.view.epoxy.controller.GenericCarouselController
|
||||||
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
|
import com.aurora.store.view.epoxy.controller.SearchCarouselController
|
||||||
import com.aurora.store.view.ui.commons.BaseFragment
|
import com.aurora.store.view.ui.commons.BaseFragment
|
||||||
import com.aurora.store.viewmodel.search.SearchResultViewModel
|
import com.aurora.store.viewmodel.search.SearchResultViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>() {
|
class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>(),
|
||||||
|
GenericCarouselController.Callbacks {
|
||||||
|
|
||||||
private val args: DevAppsFragmentArgs by navArgs()
|
private val args: DevAppsFragmentArgs by navArgs()
|
||||||
|
|
||||||
private val viewModel: SearchResultViewModel by viewModels()
|
private val viewModel: SearchResultViewModel by viewModels()
|
||||||
|
private val controller = SearchCarouselController(this)
|
||||||
|
|
||||||
|
private var query: String = ""
|
||||||
|
get() = "pub:${args.developerName}"
|
||||||
|
|
||||||
|
private var scrollListener: EndlessRecyclerOnScrollListener =
|
||||||
|
object : EndlessRecyclerOnScrollListener(visibleThreshold = 4) {
|
||||||
|
override fun onLoadMore(currentPage: Int) {
|
||||||
|
viewModel.observe(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
viewModel.liveData.observe(viewLifecycleOwner) {
|
with(binding) {
|
||||||
updateController(it)
|
toolbar.apply {
|
||||||
}
|
title = args.developerName
|
||||||
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
// Toolbar
|
|
||||||
binding.toolbar.apply {
|
|
||||||
title = args.developerName
|
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recycler View
|
|
||||||
val endlessRecyclerOnScrollListener = object : EndlessRecyclerOnScrollListener() {
|
|
||||||
override fun onLoadMore(currentPage: Int) {
|
|
||||||
viewModel.liveData.value?.let { viewModel.next(it.subBundles) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recycler.setController(controller)
|
||||||
|
recycler.addOnScrollListener(scrollListener)
|
||||||
}
|
}
|
||||||
binding.recycler.addOnScrollListener(endlessRecyclerOnScrollListener)
|
|
||||||
|
|
||||||
viewModel.observeSearchResults("pub:${args.developerName}")
|
with(viewModel) {
|
||||||
}
|
search("pub:${args.developerName}")
|
||||||
|
|
||||||
private fun updateController(searchBundle: SearchBundle) {
|
liveData.observe(viewLifecycleOwner) {
|
||||||
binding.recycler
|
when (it) {
|
||||||
.withModels {
|
is ViewState.Loading -> {
|
||||||
setFilterDuplicates(true)
|
controller.setData(null)
|
||||||
searchBundle.appList
|
|
||||||
.filter { it.displayName.isNotEmpty() }
|
|
||||||
.forEach { app ->
|
|
||||||
add(
|
|
||||||
AppListViewModel_()
|
|
||||||
.id(app.id)
|
|
||||||
.app(app)
|
|
||||||
.click(View.OnClickListener {
|
|
||||||
openDetailsFragment(app.packageName, app)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchBundle.subBundles.isNotEmpty()) {
|
is ViewState.Success<*> -> {
|
||||||
add(
|
val stash = it.getDataAs<AppStreamStash>()
|
||||||
AppProgressViewModel_()
|
controller.setData(stash[query])
|
||||||
.id("progress")
|
}
|
||||||
)
|
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHeaderClicked(streamCluster: StreamCluster) {
|
||||||
|
openStreamBrowseFragment(streamCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClusterScrolled(streamCluster: StreamCluster) {
|
||||||
|
viewModel.observeCluster(query, streamCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAppClick(app: App) {
|
||||||
|
openDetailsFragment(app.packageName, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAppLongClick(app: App) {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,202 +19,100 @@
|
|||||||
|
|
||||||
package com.aurora.store.view.ui.search
|
package com.aurora.store.view.ui.search
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.aurora.extensions.hideKeyboard
|
import com.aurora.extensions.hideKeyboard
|
||||||
import com.aurora.extensions.showKeyboard
|
import com.aurora.extensions.showKeyboard
|
||||||
import com.aurora.gplayapi.data.models.App
|
import com.aurora.gplayapi.data.models.App
|
||||||
import com.aurora.gplayapi.data.models.SearchBundle
|
import com.aurora.gplayapi.data.models.StreamCluster
|
||||||
|
import com.aurora.store.AppStreamStash
|
||||||
import com.aurora.store.R
|
import com.aurora.store.R
|
||||||
import com.aurora.store.data.model.Filter
|
import com.aurora.store.data.model.ViewState
|
||||||
import com.aurora.store.data.providers.FilterProvider.Companion.PREFERENCE_FILTER
|
import com.aurora.store.data.model.ViewState.Loading.getDataAs
|
||||||
import com.aurora.store.databinding.FragmentSearchResultBinding
|
import com.aurora.store.databinding.FragmentSearchResultBinding
|
||||||
import com.aurora.store.util.Preferences
|
|
||||||
import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener
|
import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener
|
||||||
import com.aurora.store.view.epoxy.views.AppProgressViewModel_
|
import com.aurora.store.view.epoxy.controller.GenericCarouselController
|
||||||
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
|
import com.aurora.store.view.epoxy.controller.SearchCarouselController
|
||||||
import com.aurora.store.view.epoxy.views.app.NoAppViewModel_
|
|
||||||
import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_
|
|
||||||
import com.aurora.store.view.ui.commons.BaseFragment
|
import com.aurora.store.view.ui.commons.BaseFragment
|
||||||
import com.aurora.store.viewmodel.search.SearchResultViewModel
|
import com.aurora.store.viewmodel.search.SearchResultViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class SearchResultsFragment : BaseFragment<FragmentSearchResultBinding>(),
|
class SearchResultsFragment : BaseFragment<FragmentSearchResultBinding>(),
|
||||||
OnSharedPreferenceChangeListener {
|
GenericCarouselController.Callbacks {
|
||||||
|
|
||||||
private val viewModel: SearchResultViewModel by viewModels()
|
private val viewModel: SearchResultViewModel by activityViewModels()
|
||||||
|
private val controller = SearchCarouselController(this)
|
||||||
|
|
||||||
private lateinit var sharedPreferences: SharedPreferences
|
private var query: String
|
||||||
|
get() = requireArguments().getString("query").orEmpty()
|
||||||
|
set(value) = requireArguments().putString("query", value)
|
||||||
|
|
||||||
private var query: String? = null
|
private var scrollListener: EndlessRecyclerOnScrollListener =
|
||||||
private var searchBundle: SearchBundle = SearchBundle()
|
object : EndlessRecyclerOnScrollListener(visibleThreshold = 4) {
|
||||||
|
override fun onLoadMore(currentPage: Int) {
|
||||||
private var shimmerAnimationVisible = false
|
viewModel.observe(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
// Adjust FAB margins for edgeToEdge display
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.filterFab) { _, windowInsets ->
|
|
||||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
|
||||||
binding.filterFab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.margin_normal)
|
|
||||||
}
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedPreferences = Preferences.getPrefs(view.context)
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
|
||||||
|
|
||||||
// Toolbar
|
|
||||||
binding.toolbar.apply {
|
|
||||||
setNavigationOnClickListener {
|
|
||||||
binding.searchBar.hideKeyboard()
|
|
||||||
findNavController().navigateUp()
|
|
||||||
}
|
|
||||||
setOnMenuItemClickListener {
|
|
||||||
when (it.itemId) {
|
|
||||||
R.id.action_clear -> {
|
|
||||||
binding.searchBar.text?.clear()
|
|
||||||
binding.searchBar.showKeyboard()
|
|
||||||
}
|
|
||||||
R.id.action_download -> findNavController().navigate(R.id.downloadFragment)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
attachSearch()
|
attachSearch()
|
||||||
|
with(binding) {
|
||||||
// RecyclerView
|
toolbar.apply {
|
||||||
val endlessRecyclerOnScrollListener = object : EndlessRecyclerOnScrollListener() {
|
setNavigationOnClickListener {
|
||||||
override fun onLoadMore(currentPage: Int) {
|
binding.searchBar.hideKeyboard()
|
||||||
viewModel.next(searchBundle.subBundles)
|
findNavController().navigateUp()
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.recycler.addOnScrollListener(endlessRecyclerOnScrollListener)
|
|
||||||
|
|
||||||
// Filter
|
|
||||||
binding.filterFab.setOnClickListener {
|
|
||||||
findNavController().navigate(R.id.filterSheet)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.liveData.observe(viewLifecycleOwner) {
|
|
||||||
if (shimmerAnimationVisible) {
|
|
||||||
endlessRecyclerOnScrollListener.resetPageCount()
|
|
||||||
binding.recycler.clear()
|
|
||||||
shimmerAnimationVisible = false
|
|
||||||
}
|
|
||||||
searchBundle = it
|
|
||||||
updateController(searchBundle)
|
|
||||||
}
|
|
||||||
|
|
||||||
query = requireArguments().getString("query")
|
|
||||||
|
|
||||||
// Don't fetch search results again when coming back to fragment
|
|
||||||
if (searchBundle.appList.isEmpty()) {
|
|
||||||
query?.let { updateQuery(it) }
|
|
||||||
} else {
|
|
||||||
updateController(searchBundle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
viewModel.filterProvider.saveFilter(Filter())
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateController(searchBundle: SearchBundle?) {
|
|
||||||
if (searchBundle == null) {
|
|
||||||
shimmerAnimationVisible = true
|
|
||||||
binding.recycler.withModels {
|
|
||||||
for (i in 1..10) {
|
|
||||||
add(AppListViewShimmerModel_().id(i))
|
|
||||||
}
|
}
|
||||||
}
|
setOnMenuItemClickListener {
|
||||||
return
|
when (it.itemId) {
|
||||||
}
|
R.id.action_clear -> {
|
||||||
|
binding.searchBar.text?.clear()
|
||||||
val filteredAppList = filter(searchBundle.appList)
|
binding.searchBar.showKeyboard()
|
||||||
|
|
||||||
if (filteredAppList.isEmpty()) {
|
|
||||||
if (searchBundle.subBundles.isNotEmpty()) {
|
|
||||||
viewModel.next(searchBundle.subBundles)
|
|
||||||
binding.recycler.withModels {
|
|
||||||
setFilterDuplicates(true)
|
|
||||||
add(
|
|
||||||
AppProgressViewModel_()
|
|
||||||
.id("progress")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.recycler.adapter?.let {
|
|
||||||
/*Show empty search list if nothing found or no app matches filter criterion*/
|
|
||||||
if (it.itemCount == 1 && searchBundle.subBundles.isEmpty()) {
|
|
||||||
binding.recycler.withModels {
|
|
||||||
add(
|
|
||||||
NoAppViewModel_()
|
|
||||||
.id("no_app")
|
|
||||||
.message(R.string.details_no_app_match)
|
|
||||||
.icon(R.drawable.ic_round_search)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_download -> findNavController().navigate(R.id.downloadFragment)
|
||||||
}
|
}
|
||||||
}
|
true
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.recycler.withModels {
|
|
||||||
setFilterDuplicates(true)
|
|
||||||
|
|
||||||
filteredAppList.forEach { app ->
|
|
||||||
add(
|
|
||||||
AppListViewModel_()
|
|
||||||
.id(app.id)
|
|
||||||
.app(app)
|
|
||||||
.click(View.OnClickListener {
|
|
||||||
binding.searchBar.hideKeyboard()
|
|
||||||
openDetailsFragment(app.packageName, app)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchBundle.subBundles.isNotEmpty()) {
|
|
||||||
add(
|
|
||||||
AppProgressViewModel_()
|
|
||||||
.id("progress")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.recycler.adapter?.let {
|
recycler.setController(controller)
|
||||||
if (it.itemCount < 10) {
|
recycler.addOnScrollListener(scrollListener)
|
||||||
viewModel.next(searchBundle.subBundles)
|
}
|
||||||
|
|
||||||
|
with(viewModel) {
|
||||||
|
search(query)
|
||||||
|
|
||||||
|
liveData.observe(viewLifecycleOwner) {
|
||||||
|
when (it) {
|
||||||
|
is ViewState.Loading -> {
|
||||||
|
controller.setData(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ViewState.Success<*> -> {
|
||||||
|
val stash = it.getDataAs<AppStreamStash>()
|
||||||
|
controller.setData(stash[query])
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachSearch() {
|
private fun attachSearch() {
|
||||||
|
binding.searchBar.text = Editable.Factory.getInstance().newEditable(query)
|
||||||
|
|
||||||
binding.searchBar.addTextChangedListener(object : TextWatcher {
|
binding.searchBar.addTextChangedListener(object : TextWatcher {
|
||||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
|
||||||
|
|
||||||
@@ -230,44 +128,35 @@ class SearchResultsFragment : BaseFragment<FragmentSearchResultBinding>(),
|
|||||||
|| actionId == KeyEvent.ACTION_DOWN
|
|| actionId == KeyEvent.ACTION_DOWN
|
||||||
|| actionId == KeyEvent.KEYCODE_ENTER
|
|| actionId == KeyEvent.KEYCODE_ENTER
|
||||||
) {
|
) {
|
||||||
|
|
||||||
query = binding.searchBar.text.toString()
|
query = binding.searchBar.text.toString()
|
||||||
query?.let {
|
|
||||||
requireArguments().putString("query", it)
|
queryViewModel(query)
|
||||||
queryViewModel(it)
|
|
||||||
return@setOnEditorActionListener true
|
return@setOnEditorActionListener true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateQuery(query: String) {
|
|
||||||
binding.searchBar.text = Editable.Factory.getInstance().newEditable(query)
|
|
||||||
binding.searchBar.setSelection(query.length)
|
|
||||||
queryViewModel(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun queryViewModel(query: String) {
|
private fun queryViewModel(query: String) {
|
||||||
updateController(null)
|
scrollListener.resetPageCount()
|
||||||
viewModel.observeSearchResults(query)
|
viewModel.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun filter(appList: List<App>): List<App> {
|
override fun onHeaderClicked(streamCluster: StreamCluster) {
|
||||||
val filter = viewModel.filterProvider.getSavedFilter()
|
openStreamBrowseFragment(streamCluster)
|
||||||
return appList
|
|
||||||
.asSequence()
|
|
||||||
.filter { app ->
|
|
||||||
app.displayName.isNotEmpty() &&
|
|
||||||
(filter.paidApps || app.isFree) &&
|
|
||||||
(filter.appsWithAds || !app.containsAds) &&
|
|
||||||
(filter.gsfDependentApps || app.dependencies.dependentPackages.isEmpty()) &&
|
|
||||||
(filter.rating <= 0 || app.rating.average >= filter.rating) &&
|
|
||||||
(filter.downloads <= 0 || app.installs >= filter.downloads)
|
|
||||||
}
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
override fun onClusterScrolled(streamCluster: StreamCluster) {
|
||||||
if (key == PREFERENCE_FILTER) query?.let { queryViewModel(it) }
|
viewModel.observeCluster(query, streamCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAppClick(app: App) {
|
||||||
|
openDetailsFragment(app.packageName, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAppLongClick(app: App) {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,21 +23,23 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.aurora.gplayapi.data.models.SearchBundle
|
import com.aurora.gplayapi.data.models.StreamBundle
|
||||||
|
import com.aurora.gplayapi.data.models.StreamCluster
|
||||||
import com.aurora.gplayapi.helpers.SearchHelper
|
import com.aurora.gplayapi.helpers.SearchHelper
|
||||||
import com.aurora.gplayapi.helpers.contracts.SearchContract
|
import com.aurora.gplayapi.helpers.contracts.SearchContract
|
||||||
import com.aurora.gplayapi.helpers.web.WebSearchHelper
|
import com.aurora.gplayapi.helpers.web.WebSearchHelper
|
||||||
|
import com.aurora.store.AppStreamStash
|
||||||
|
import com.aurora.store.data.model.ViewState
|
||||||
import com.aurora.store.data.providers.AuthProvider
|
import com.aurora.store.data.providers.AuthProvider
|
||||||
import com.aurora.store.data.providers.FilterProvider
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SearchResultViewModel @Inject constructor(
|
class SearchResultViewModel @Inject constructor(
|
||||||
val filterProvider: FilterProvider,
|
|
||||||
private val authProvider: AuthProvider,
|
private val authProvider: AuthProvider,
|
||||||
private val searchHelper: SearchHelper,
|
private val searchHelper: SearchHelper,
|
||||||
private val webSearchHelper: WebSearchHelper
|
private val webSearchHelper: WebSearchHelper
|
||||||
@@ -45,50 +47,146 @@ class SearchResultViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val TAG = SearchResultViewModel::class.java.simpleName
|
private val TAG = SearchResultViewModel::class.java.simpleName
|
||||||
|
|
||||||
val liveData: MutableLiveData<SearchBundle> = MutableLiveData()
|
val liveData: MutableLiveData<ViewState> = MutableLiveData()
|
||||||
|
|
||||||
private var searchBundle: SearchBundle = SearchBundle()
|
private val stash: AppStreamStash = mutableMapOf()
|
||||||
|
|
||||||
private val helper: SearchContract
|
private val contract: SearchContract
|
||||||
get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper
|
get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper
|
||||||
|
|
||||||
fun observeSearchResults(query: String) {
|
private val stashMutex = Mutex()
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
supervisorScope {
|
|
||||||
try {
|
|
||||||
searchBundle = search(query)
|
|
||||||
liveData.postValue(searchBundle)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun search(query: String): SearchBundle {
|
|
||||||
return helper.searchResults(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun next(nextSubBundleSet: Set<SearchBundle.SubBundle>) {
|
fun search(query: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
supervisorScope {
|
try {
|
||||||
try {
|
stashMutex.withLock {
|
||||||
if (nextSubBundleSet.isNotEmpty()) {
|
liveData.postValue(ViewState.Loading)
|
||||||
val newSearchBundle = helper.next(nextSubBundleSet.toMutableSet())
|
|
||||||
if (newSearchBundle.appList.isNotEmpty()) {
|
|
||||||
searchBundle = searchBundle.copy(
|
|
||||||
subBundles = newSearchBundle.subBundles,
|
|
||||||
appList = searchBundle.appList + newSearchBundle.appList
|
|
||||||
)
|
|
||||||
|
|
||||||
liveData.postValue(searchBundle)
|
var bundle = targetBundle(query)
|
||||||
}
|
|
||||||
|
// Post existing data if any clusters exist
|
||||||
|
if (bundle.hasCluster()) {
|
||||||
|
liveData.postValue(ViewState.Success(stash.toMap()))
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(TAG, "Failed to get next bundle", e)
|
// Fetch new stream bundle
|
||||||
|
val newBundle = contract.searchResults(query)
|
||||||
|
|
||||||
|
bundle = bundle.copy(
|
||||||
|
streamClusters = newBundle.streamClusters,
|
||||||
|
streamNextPageUrl = newBundle.streamNextPageUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
stash[query] = bundle
|
||||||
|
|
||||||
|
liveData.postValue(ViewState.Success(stash.toMap()))
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
liveData.postValue(ViewState.Error(e.message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun observe(query: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
stashMutex.withLock {
|
||||||
|
var bundle = targetBundle(query)
|
||||||
|
|
||||||
|
if (bundle.hasNext()) {
|
||||||
|
val newBundle = contract.nextStreamBundle(
|
||||||
|
query,
|
||||||
|
bundle.streamNextPageUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update old bundle
|
||||||
|
bundle = bundle.copy(
|
||||||
|
streamClusters = bundle.streamClusters + newBundle.streamClusters,
|
||||||
|
streamNextPageUrl = newBundle.streamNextPageUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
stash[query] = bundle
|
||||||
|
|
||||||
|
liveData.postValue(ViewState.Success(stash.toMap()))
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "End of Bundle")
|
||||||
|
|
||||||
|
// If stream ends, likely there are clusters that need to be processed
|
||||||
|
bundle.streamClusters.values.forEach {
|
||||||
|
if (it.clusterNextPageUrl.isEmpty()) {
|
||||||
|
postClusterEnd(query, it.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty title or query as title indicates main stream cluster
|
||||||
|
if (it.clusterTitle.isEmpty() or (it.clusterTitle == bundle.streamTitle)) {
|
||||||
|
observeCluster(query, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
liveData.postValue(ViewState.Error(e.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeCluster(query: String, streamCluster: StreamCluster) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (streamCluster.hasNext()) {
|
||||||
|
val newCluster = contract.nextStreamCluster(
|
||||||
|
query,
|
||||||
|
streamCluster.clusterNextPageUrl
|
||||||
|
)
|
||||||
|
stashMutex.withLock {
|
||||||
|
updateCluster(query, streamCluster.id, newCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
liveData.postValue(ViewState.Success(stash.toMap()))
|
||||||
|
} else {
|
||||||
|
stashMutex.withLock {
|
||||||
|
postClusterEnd(query, streamCluster.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
liveData.postValue(ViewState.Success(stash.toMap()))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
liveData.postValue(ViewState.Error(e.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCluster(query: String, clusterID: Int, newCluster: StreamCluster) {
|
||||||
|
val bundle = stash[query] ?: return
|
||||||
|
val oldCluster = bundle.streamClusters[clusterID] ?: return
|
||||||
|
|
||||||
|
val mergedCluster = oldCluster.copy(
|
||||||
|
clusterNextPageUrl = newCluster.clusterNextPageUrl,
|
||||||
|
clusterAppList = oldCluster.clusterAppList + newCluster.clusterAppList
|
||||||
|
)
|
||||||
|
|
||||||
|
val updatedClusters = bundle.streamClusters.toMutableMap().apply {
|
||||||
|
this[clusterID] = mergedCluster
|
||||||
|
}
|
||||||
|
|
||||||
|
stash[query] = bundle.copy(streamClusters = updatedClusters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postClusterEnd(query: String, clusterID: Int) {
|
||||||
|
val bundle = stash[query] ?: return
|
||||||
|
val oldCluster = bundle.streamClusters[clusterID] ?: return
|
||||||
|
|
||||||
|
val updatedCluster = oldCluster.copy(clusterNextPageUrl = "")
|
||||||
|
val updatedClusters = bundle.streamClusters.toMutableMap().apply {
|
||||||
|
this[clusterID] = updatedCluster
|
||||||
|
}
|
||||||
|
|
||||||
|
stash[query] = bundle.copy(streamClusters = updatedClusters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun targetBundle(query: String): StreamBundle {
|
||||||
|
return stash.getOrPut(query.trim()) { StreamBundle(streamTitle = query) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
~
|
~
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/coordinator"
|
android:id="@+id/coordinator"
|
||||||
@@ -25,55 +25,40 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".view.ui.search.SearchResultsFragment">
|
tools:context=".view.ui.search.SearchResultsFragment">
|
||||||
|
|
||||||
<RelativeLayout
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="?attr/actionBarSize"
|
||||||
|
app:menu="@menu/menu_search"
|
||||||
|
app:navigationIcon="@drawable/ic_arrow_back">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/searchBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:background="@null"
|
||||||
|
android:hint="@string/search_hint"
|
||||||
|
android:imeOptions="flagNoExtractUi|actionSearch"
|
||||||
|
android:inputType="text"
|
||||||
|
android:paddingStart="@dimen/padding_large"
|
||||||
|
android:paddingEnd="@dimen/padding_normal"
|
||||||
|
android:singleLine="true" />
|
||||||
|
</androidx.appcompat.widget.Toolbar>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:id="@+id/divider"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@id/toolbar" />
|
||||||
|
|
||||||
|
<com.airbnb.epoxy.EpoxyRecyclerView
|
||||||
|
android:id="@+id/recycler"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical">
|
android:layout_below="@+id/divider"
|
||||||
|
android:clipToPadding="true"
|
||||||
<androidx.appcompat.widget.Toolbar
|
android:paddingTop="@dimen/padding_normal"
|
||||||
android:id="@+id/toolbar"
|
android:paddingBottom="@dimen/height_bottom_adj"
|
||||||
android:layout_width="match_parent"
|
tools:listitem="@layout/view_app_list" />
|
||||||
android:layout_height="wrap_content"
|
</RelativeLayout>
|
||||||
android:minHeight="?attr/actionBarSize"
|
|
||||||
app:menu="@menu/menu_search"
|
|
||||||
app:navigationIcon="@drawable/ic_arrow_back">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:id="@+id/searchBar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="42dp"
|
|
||||||
android:background="@null"
|
|
||||||
android:hint="@string/search_hint"
|
|
||||||
android:imeOptions="flagNoExtractUi|actionSearch"
|
|
||||||
android:inputType="text"
|
|
||||||
android:paddingStart="@dimen/padding_large"
|
|
||||||
android:paddingEnd="@dimen/padding_normal"
|
|
||||||
android:singleLine="true" />
|
|
||||||
</androidx.appcompat.widget.Toolbar>
|
|
||||||
|
|
||||||
<com.google.android.material.divider.MaterialDivider
|
|
||||||
android:id="@+id/divider"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_below="@id/toolbar" />
|
|
||||||
|
|
||||||
<com.airbnb.epoxy.EpoxyRecyclerView
|
|
||||||
android:id="@+id/recycler"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_below="@+id/divider"
|
|
||||||
android:clipToPadding="true"
|
|
||||||
android:paddingTop="@dimen/padding_normal"
|
|
||||||
android:paddingBottom="@dimen/height_bottom_adj"
|
|
||||||
tools:listitem="@layout/view_app_list" />
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
|
||||||
android:id="@+id/filter_fab"
|
|
||||||
style="@style/Widget.Material3.ExtendedFloatingActionButton.Icon.Primary"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center_horizontal|bottom"
|
|
||||||
android:text="@string/action_filter"
|
|
||||||
app:icon="@drawable/ic_filter" />
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ composeBom = "2025.05.01"
|
|||||||
core = "1.16.0"
|
core = "1.16.0"
|
||||||
epoxy = "5.1.4"
|
epoxy = "5.1.4"
|
||||||
espresso = "3.6.1"
|
espresso = "3.6.1"
|
||||||
gplayapi = "3.5.3"
|
gplayapi = "3.5.5"
|
||||||
hiddenapibypass = "6.1"
|
hiddenapibypass = "6.1"
|
||||||
hilt = "2.56.2"
|
hilt = "2.56.2"
|
||||||
hiltWork = "1.2.0"
|
hiltWork = "1.2.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user