mirror of
https://github.com/whyorean/AuroraStore.git
synced 2026-04-25 01:10:51 -04:00
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.navigation.fragment.findNavController
|
||||
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.view.custom.recycler.EndlessRecyclerOnScrollListener
|
||||
import com.aurora.store.view.epoxy.views.AppProgressViewModel_
|
||||
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
|
||||
import com.aurora.store.view.epoxy.controller.GenericCarouselController
|
||||
import com.aurora.store.view.epoxy.controller.SearchCarouselController
|
||||
import com.aurora.store.view.ui.commons.BaseFragment
|
||||
import com.aurora.store.viewmodel.search.SearchResultViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>() {
|
||||
class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>(),
|
||||
GenericCarouselController.Callbacks {
|
||||
|
||||
private val args: DevAppsFragmentArgs by navArgs()
|
||||
|
||||
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?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel.liveData.observe(viewLifecycleOwner) {
|
||||
updateController(it)
|
||||
}
|
||||
|
||||
// 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) }
|
||||
with(binding) {
|
||||
toolbar.apply {
|
||||
title = args.developerName
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
}
|
||||
|
||||
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) {
|
||||
binding.recycler
|
||||
.withModels {
|
||||
setFilterDuplicates(true)
|
||||
searchBundle.appList
|
||||
.filter { it.displayName.isNotEmpty() }
|
||||
.forEach { app ->
|
||||
add(
|
||||
AppListViewModel_()
|
||||
.id(app.id)
|
||||
.app(app)
|
||||
.click(View.OnClickListener {
|
||||
openDetailsFragment(app.packageName, app)
|
||||
})
|
||||
)
|
||||
liveData.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
is ViewState.Loading -> {
|
||||
controller.setData(null)
|
||||
}
|
||||
|
||||
if (searchBundle.subBundles.isNotEmpty()) {
|
||||
add(
|
||||
AppProgressViewModel_()
|
||||
.id("progress")
|
||||
)
|
||||
is ViewState.Success<*> -> {
|
||||
val stash = it.getDataAs<AppStreamStash>()
|
||||
controller.setData(stash[query])
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.aurora.extensions.hideKeyboard
|
||||
import com.aurora.extensions.showKeyboard
|
||||
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.data.model.Filter
|
||||
import com.aurora.store.data.providers.FilterProvider.Companion.PREFERENCE_FILTER
|
||||
import com.aurora.store.data.model.ViewState
|
||||
import com.aurora.store.data.model.ViewState.Loading.getDataAs
|
||||
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.epoxy.views.AppProgressViewModel_
|
||||
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_
|
||||
import com.aurora.store.view.epoxy.controller.GenericCarouselController
|
||||
import com.aurora.store.view.epoxy.controller.SearchCarouselController
|
||||
import com.aurora.store.view.ui.commons.BaseFragment
|
||||
import com.aurora.store.viewmodel.search.SearchResultViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
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 searchBundle: SearchBundle = SearchBundle()
|
||||
|
||||
private var shimmerAnimationVisible = false
|
||||
private var scrollListener: EndlessRecyclerOnScrollListener =
|
||||
object : EndlessRecyclerOnScrollListener(visibleThreshold = 4) {
|
||||
override fun onLoadMore(currentPage: Int) {
|
||||
viewModel.observe(query)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
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
|
||||
attachSearch()
|
||||
|
||||
// RecyclerView
|
||||
val endlessRecyclerOnScrollListener = object : EndlessRecyclerOnScrollListener() {
|
||||
override fun onLoadMore(currentPage: Int) {
|
||||
viewModel.next(searchBundle.subBundles)
|
||||
}
|
||||
}
|
||||
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))
|
||||
with(binding) {
|
||||
toolbar.apply {
|
||||
setNavigationOnClickListener {
|
||||
binding.searchBar.hideKeyboard()
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val filteredAppList = filter(searchBundle.appList)
|
||||
|
||||
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)
|
||||
)
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_clear -> {
|
||||
binding.searchBar.text?.clear()
|
||||
binding.searchBar.showKeyboard()
|
||||
}
|
||||
|
||||
R.id.action_download -> findNavController().navigate(R.id.downloadFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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")
|
||||
)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
binding.recycler.adapter?.let {
|
||||
if (it.itemCount < 10) {
|
||||
viewModel.next(searchBundle.subBundles)
|
||||
recycler.setController(controller)
|
||||
recycler.addOnScrollListener(scrollListener)
|
||||
}
|
||||
|
||||
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() {
|
||||
binding.searchBar.text = Editable.Factory.getInstance().newEditable(query)
|
||||
|
||||
binding.searchBar.addTextChangedListener(object : TextWatcher {
|
||||
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.KEYCODE_ENTER
|
||||
) {
|
||||
|
||||
query = binding.searchBar.text.toString()
|
||||
query?.let {
|
||||
requireArguments().putString("query", it)
|
||||
queryViewModel(it)
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
|
||||
queryViewModel(query)
|
||||
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
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) {
|
||||
updateController(null)
|
||||
viewModel.observeSearchResults(query)
|
||||
scrollListener.resetPageCount()
|
||||
viewModel.search(query)
|
||||
}
|
||||
|
||||
private fun filter(appList: List<App>): List<App> {
|
||||
val filter = viewModel.filterProvider.getSavedFilter()
|
||||
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 onHeaderClicked(streamCluster: StreamCluster) {
|
||||
openStreamBrowseFragment(streamCluster)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == PREFERENCE_FILTER) query?.let { queryViewModel(it) }
|
||||
override fun onClusterScrolled(streamCluster: StreamCluster) {
|
||||
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.ViewModel
|
||||
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.contracts.SearchContract
|
||||
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.FilterProvider
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SearchResultViewModel @Inject constructor(
|
||||
val filterProvider: FilterProvider,
|
||||
private val authProvider: AuthProvider,
|
||||
private val searchHelper: SearchHelper,
|
||||
private val webSearchHelper: WebSearchHelper
|
||||
@@ -45,50 +47,146 @@ class SearchResultViewModel @Inject constructor(
|
||||
|
||||
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
|
||||
|
||||
fun observeSearchResults(query: String) {
|
||||
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)
|
||||
}
|
||||
private val stashMutex = Mutex()
|
||||
|
||||
@Synchronized
|
||||
fun next(nextSubBundleSet: Set<SearchBundle.SubBundle>) {
|
||||
fun search(query: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
supervisorScope {
|
||||
try {
|
||||
if (nextSubBundleSet.isNotEmpty()) {
|
||||
val newSearchBundle = helper.next(nextSubBundleSet.toMutableSet())
|
||||
if (newSearchBundle.appList.isNotEmpty()) {
|
||||
searchBundle = searchBundle.copy(
|
||||
subBundles = newSearchBundle.subBundles,
|
||||
appList = searchBundle.appList + newSearchBundle.appList
|
||||
)
|
||||
try {
|
||||
stashMutex.withLock {
|
||||
liveData.postValue(ViewState.Loading)
|
||||
|
||||
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:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/coordinator"
|
||||
@@ -25,55 +25,40 @@
|
||||
android:layout_height="match_parent"
|
||||
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_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<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_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>
|
||||
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>
|
||||
|
||||
@@ -9,7 +9,7 @@ composeBom = "2025.05.01"
|
||||
core = "1.16.0"
|
||||
epoxy = "5.1.4"
|
||||
espresso = "3.6.1"
|
||||
gplayapi = "3.5.3"
|
||||
gplayapi = "3.5.5"
|
||||
hiddenapibypass = "6.1"
|
||||
hilt = "2.56.2"
|
||||
hiltWork = "1.2.0"
|
||||
|
||||
Reference in New Issue
Block a user