mirror of
https://github.com/nextcloud/android.git
synced 2024-11-22 21:25:35 +03:00
Unified search: basic "Load more" functionality
Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>
This commit is contained in:
parent
02d133a1c7
commit
e8feb412a4
12 changed files with 229 additions and 61 deletions
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
*
|
||||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Álvaro Brey Vilas
|
||||
* Copyright (C) 2021 Álvaro Brey Vilas
|
||||
* Copyright (C) 2020 Nextcloud GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.owncloud.android.ui.adapter
|
||||
|
||||
import android.content.Context
|
||||
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
|
||||
import com.owncloud.android.databinding.UnifiedSearchFooterBinding
|
||||
import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface
|
||||
|
||||
class UnifiedSearchFooterViewHolder(
|
||||
val binding: UnifiedSearchFooterBinding,
|
||||
val context: Context,
|
||||
private val listInterface: UnifiedSearchListInterface,
|
||||
) :
|
||||
SectionedViewHolder(binding.root) {
|
||||
|
||||
fun bind(section: UnifiedSearchSection) {
|
||||
binding.unifiedSearchFooterLayout.setOnClickListener {
|
||||
listInterface.onLoadMoreClicked(section.providerID)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,14 +24,13 @@ package com.owncloud.android.ui.adapter
|
|||
import android.content.Context
|
||||
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
|
||||
import com.owncloud.android.databinding.UnifiedSearchHeaderBinding
|
||||
import com.owncloud.android.lib.common.SearchResult
|
||||
import com.owncloud.android.utils.theme.ThemeColorUtils
|
||||
|
||||
class UnifiedSearchHeaderViewHolder(val binding: UnifiedSearchHeaderBinding, val context: Context) :
|
||||
SectionedViewHolder(binding.root) {
|
||||
|
||||
fun bind(searchResult: SearchResult) {
|
||||
binding.title.text = searchResult.name
|
||||
fun bind(section: UnifiedSearchSection) {
|
||||
binding.title.text = section.name
|
||||
binding.title.setTextColor(ThemeColorUtils.primaryColor(context))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,15 +31,29 @@ import com.nextcloud.client.network.ClientFactory
|
|||
import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter
|
||||
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
|
||||
import com.owncloud.android.lib.common.SearchResult
|
||||
import java.util.ArrayList
|
||||
import android.view.ViewGroup
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import kotlin.NotImplementedError
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.databinding.UnifiedSearchFooterBinding
|
||||
import com.owncloud.android.databinding.UnifiedSearchHeaderBinding
|
||||
import com.owncloud.android.databinding.UnifiedSearchItemBinding
|
||||
import com.owncloud.android.datamodel.ThumbnailsCacheManager.InitDiskCacheTask
|
||||
import com.owncloud.android.ui.unifiedsearch.ProviderID
|
||||
|
||||
data class UnifiedSearchSection(val providerID: ProviderID, val results: List<SearchResult>) {
|
||||
val itemCount: Int = results.sumOf { it.entries.size }
|
||||
|
||||
val name: String = results.first().name
|
||||
|
||||
val nextCursor: Int? = results.lastOrNull()?.cursor?.toInt()
|
||||
|
||||
fun getItem(index: Int) = results.flatMap { it.entries }[index]
|
||||
|
||||
fun hasMoreResults(): Boolean {
|
||||
return results.last().isPaginated && nextCursor == itemCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This Adapter populates a SectionedRecyclerView with search results by unified search
|
||||
|
@ -55,52 +69,59 @@ class UnifiedSearchListAdapter(
|
|||
private const val FILES_PROVIDER_ID = "files"
|
||||
}
|
||||
|
||||
private var list: List<SearchResult> = ArrayList()
|
||||
private var data: Map<ProviderID, List<SearchResult>> = emptyMap()
|
||||
private var sections: List<UnifiedSearchSection> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionedViewHolder {
|
||||
return if (viewType == VIEW_TYPE_HEADER) {
|
||||
val binding = UnifiedSearchHeaderBinding.inflate(
|
||||
LayoutInflater.from(
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_HEADER -> {
|
||||
val binding = UnifiedSearchHeaderBinding.inflate(
|
||||
LayoutInflater.from(context), parent, false
|
||||
)
|
||||
UnifiedSearchHeaderViewHolder(binding, context)
|
||||
}
|
||||
VIEW_TYPE_FOOTER -> {
|
||||
val binding = UnifiedSearchFooterBinding.inflate(
|
||||
LayoutInflater.from(context), parent, false
|
||||
)
|
||||
UnifiedSearchFooterViewHolder(binding, context, listInterface)
|
||||
}
|
||||
else -> {
|
||||
val binding = UnifiedSearchItemBinding.inflate(
|
||||
LayoutInflater.from(
|
||||
context
|
||||
),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
UnifiedSearchItemViewHolder(
|
||||
binding,
|
||||
user,
|
||||
clientFactory,
|
||||
storageManager,
|
||||
listInterface,
|
||||
context
|
||||
),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
UnifiedSearchHeaderViewHolder(binding, context)
|
||||
} else {
|
||||
val binding = UnifiedSearchItemBinding.inflate(
|
||||
LayoutInflater.from(
|
||||
context
|
||||
),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
UnifiedSearchItemViewHolder(
|
||||
binding,
|
||||
user,
|
||||
clientFactory,
|
||||
storageManager,
|
||||
listInterface,
|
||||
context
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSectionCount(): Int {
|
||||
return list.size
|
||||
return sections.size
|
||||
}
|
||||
|
||||
override fun getItemCount(section: Int): Int {
|
||||
return list[section].entries.size
|
||||
return sections[section].itemCount
|
||||
}
|
||||
|
||||
override fun onBindHeaderViewHolder(holder: SectionedViewHolder, section: Int, expanded: Boolean) {
|
||||
val headerViewHolder = holder as UnifiedSearchHeaderViewHolder
|
||||
headerViewHolder.bind(list[section])
|
||||
headerViewHolder.bind(sections[section])
|
||||
}
|
||||
|
||||
override fun onBindFooterViewHolder(holder: SectionedViewHolder, section: Int) {
|
||||
throw NotImplementedError()
|
||||
val footerViewHolder = holder as UnifiedSearchFooterViewHolder
|
||||
footerViewHolder.bind(sections[section])
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
|
@ -111,7 +132,7 @@ class UnifiedSearchListAdapter(
|
|||
) {
|
||||
// TODO different binding (and also maybe diff UI) for non-file results
|
||||
val itemViewHolder = holder as UnifiedSearchItemViewHolder
|
||||
val entry = list[section].entries[relativePosition]
|
||||
val entry = sections[section].getItem(relativePosition)
|
||||
itemViewHolder.bind(entry)
|
||||
}
|
||||
|
||||
|
@ -125,22 +146,23 @@ class UnifiedSearchListAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
fun setData(results: Map<String, SearchResult>) {
|
||||
// "Files" always goes first
|
||||
val comparator =
|
||||
Comparator { o1: Map.Entry<String, SearchResult>, o2: Map.Entry<String, SearchResult> ->
|
||||
when {
|
||||
o1.key == FILES_PROVIDER_ID -> -1
|
||||
o2.key == FILES_PROVIDER_ID -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
list = results.asSequence().sortedWith(comparator).map { it.value }.toList()
|
||||
// TODO only update where needed
|
||||
fun setInitialData(results: Map<String, List<SearchResult>>) {
|
||||
data = results
|
||||
buildSectionList()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun buildSectionList() {
|
||||
// sort so that files is always first
|
||||
sections = data.map { UnifiedSearchSection(it.key, it.value) }.sortedWith { o1, o2 ->
|
||||
when {
|
||||
o1.providerID == FILES_PROVIDER_ID -> -1
|
||||
o2.providerID == FILES_PROVIDER_ID -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// initialise thumbnails cache on background thread
|
||||
InitDiskCacheTask().execute()
|
||||
|
|
|
@ -41,6 +41,7 @@ import com.owncloud.android.ui.activity.FileDisplayActivity
|
|||
import com.owncloud.android.ui.adapter.UnifiedSearchListAdapter
|
||||
import com.owncloud.android.ui.asynctasks.GetRemoteFileTask
|
||||
import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface
|
||||
import com.owncloud.android.ui.unifiedsearch.ProviderID
|
||||
import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -103,6 +104,7 @@ class UnifiedSearchFragment : Fragment(), Injectable, UnifiedSearchListInterface
|
|||
clientFactory,
|
||||
requireContext()
|
||||
)
|
||||
adapter.shouldShowFooters(true)
|
||||
adapter.setLayoutManager(gridLayoutManager)
|
||||
binding.listRoot.layoutManager = gridLayoutManager
|
||||
binding.listRoot.adapter = adapter
|
||||
|
@ -132,6 +134,10 @@ class UnifiedSearchFragment : Fragment(), Injectable, UnifiedSearchListInterface
|
|||
openFile(searchResultEntry.remotePath())
|
||||
}
|
||||
|
||||
override fun onLoadMoreClicked(providerID: ProviderID) {
|
||||
vm.loadMore(providerID)
|
||||
}
|
||||
|
||||
fun openFile(fileUrl: String) {
|
||||
val user = currentAccountProvider.user
|
||||
val task = GetRemoteFileTask(
|
||||
|
@ -145,10 +151,10 @@ class UnifiedSearchFragment : Fragment(), Injectable, UnifiedSearchListInterface
|
|||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun onSearchResultChanged(result: Map<String, SearchResult>) {
|
||||
fun onSearchResultChanged(result: Map<String, List<SearchResult>>) {
|
||||
binding.emptyList.emptyListView.visibility = View.GONE
|
||||
|
||||
adapter.setData(result)
|
||||
adapter.setInitialData(result)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
|
|
@ -23,8 +23,10 @@
|
|||
package com.owncloud.android.ui.interfaces
|
||||
|
||||
import com.owncloud.android.lib.common.SearchResultEntry
|
||||
import com.owncloud.android.ui.unifiedsearch.ProviderID
|
||||
|
||||
interface UnifiedSearchListInterface {
|
||||
|
||||
fun onSearchResultClicked(searchResultEntry: SearchResultEntry)
|
||||
fun onLoadMoreClicked(providerID: ProviderID)
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ typealias ProviderID = String
|
|||
|
||||
data class UnifiedSearchResult(val provider: ProviderID, val success: Boolean, val result: SearchResult)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
interface IUnifiedSearchRepository {
|
||||
fun refresh()
|
||||
fun startLoading()
|
||||
|
@ -37,4 +38,13 @@ interface IUnifiedSearchRepository {
|
|||
onError: (Throwable) -> Unit,
|
||||
onFinished: (Boolean) -> Unit
|
||||
)
|
||||
|
||||
fun queryProvider(
|
||||
query: String,
|
||||
provider: ProviderID,
|
||||
cursor: Int?,
|
||||
onResult: (UnifiedSearchResult) -> Unit,
|
||||
onError: (Throwable) -> Unit,
|
||||
onFinished: (Boolean) -> Unit
|
||||
)
|
||||
}
|
||||
|
|
|
@ -27,7 +27,8 @@ import com.owncloud.android.lib.common.utils.Log_OC
|
|||
class SearchOnProviderTask(
|
||||
private val query: String,
|
||||
private val provider: String,
|
||||
private val client: NextcloudClient
|
||||
private val client: NextcloudClient,
|
||||
private val cursor: Int? = null
|
||||
) : () -> SearchOnProviderTask.Result {
|
||||
companion object {
|
||||
private const val TAG = "SearchOnProviderTask"
|
||||
|
@ -37,7 +38,7 @@ class SearchOnProviderTask(
|
|||
|
||||
override fun invoke(): Result {
|
||||
Log_OC.d(TAG, "Run task")
|
||||
val result = UnifiedSearchRemoteOperation(provider, query).execute(client)
|
||||
val result = UnifiedSearchRemoteOperation(provider, query, cursor).execute(client)
|
||||
|
||||
Log_OC.d(TAG, "Task finished: " + result.isSuccess)
|
||||
return if (result.isSuccess && result.resultData != null) {
|
||||
|
|
|
@ -49,7 +49,7 @@ class UnifiedSearchRemoteRepository(
|
|||
onError: (Throwable) -> Unit,
|
||||
onFinished: (Boolean) -> Unit
|
||||
) {
|
||||
Log_OC.d(this, "loadMore")
|
||||
Log_OC.d(this, "queryAll")
|
||||
fetchProviders(
|
||||
onResult = { result ->
|
||||
val providerIds = result.providers.map { it.id }
|
||||
|
@ -84,6 +84,30 @@ class UnifiedSearchRemoteRepository(
|
|||
)
|
||||
}
|
||||
|
||||
override fun queryProvider(
|
||||
query: String,
|
||||
provider: ProviderID,
|
||||
cursor: Int?,
|
||||
onResult: (UnifiedSearchResult) -> Unit,
|
||||
onError: (Throwable) -> Unit,
|
||||
onFinished: (Boolean) -> Unit
|
||||
) {
|
||||
Log_OC.d(
|
||||
this,
|
||||
"queryProvider() called with: query = $query, provider = $provider, cursor = $cursor"
|
||||
)
|
||||
val client = clientFactory.createNextcloudClient(currentAccountProvider.user)
|
||||
val task = SearchOnProviderTask(query, provider, client, cursor)
|
||||
asyncRunner.postQuickTask(
|
||||
task,
|
||||
onResult = {
|
||||
onResult(UnifiedSearchResult(provider, it.success, it.searchResult))
|
||||
onFinished(!it.success)
|
||||
},
|
||||
onError
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchProviders(onResult: (SearchProviders) -> Unit, onError: (Throwable) -> Unit) {
|
||||
Log_OC.d(this, "fetchProviders")
|
||||
if (this.providers != null) {
|
||||
|
|
|
@ -47,7 +47,7 @@ class UnifiedSearchViewModel() : ViewModel() {
|
|||
private var last: Int = -1
|
||||
|
||||
val isLoading: MutableLiveData<Boolean> = MutableLiveData<Boolean>(false)
|
||||
val searchResults = MutableLiveData<MutableMap<ProviderID, SearchResult>>(mutableMapOf())
|
||||
val searchResults = MutableLiveData<MutableMap<ProviderID, MutableList<SearchResult>>>(mutableMapOf())
|
||||
val error: MutableLiveData<String> = MutableLiveData<String>("")
|
||||
val query: MutableLiveData<String> = MutableLiveData()
|
||||
|
||||
|
@ -99,8 +99,22 @@ class UnifiedSearchViewModel() : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
open fun loadMore() {
|
||||
// TODO load more results for a single provider
|
||||
open fun loadMore(provider: ProviderID) {
|
||||
val queryTerm = query.value.orEmpty()
|
||||
|
||||
if (isLoading.value != true && queryTerm.isNotBlank()) {
|
||||
isLoading.value = true
|
||||
val providerResults = searchResults.value?.get(provider)
|
||||
val cursor = providerResults?.filter { it.cursor != null }?.maxOfOrNull { it.cursor!!.toInt() }
|
||||
repository.queryProvider(
|
||||
queryTerm,
|
||||
provider,
|
||||
cursor,
|
||||
this::onSearchResult,
|
||||
this::onError,
|
||||
this::onSearchFinished
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openFile(fileUrl: String) {
|
||||
|
@ -131,9 +145,10 @@ class UnifiedSearchViewModel() : ViewModel() {
|
|||
isLoading.value = false
|
||||
|
||||
if (result.success) {
|
||||
// TODO append if already exists
|
||||
val currentValues: MutableMap<ProviderID, SearchResult> = searchResults.value ?: mutableMapOf()
|
||||
currentValues.put(result.provider, result.result)
|
||||
val currentValues: MutableMap<ProviderID, MutableList<SearchResult>> = searchResults.value ?: mutableMapOf()
|
||||
val providerValues = currentValues[result.provider] ?: mutableListOf()
|
||||
providerValues.add(result.result)
|
||||
currentValues.put(result.provider, providerValues)
|
||||
searchResults.value = currentValues
|
||||
}
|
||||
|
||||
|
|
48
src/main/res/layout/unified_search_footer.xml
Executable file
48
src/main/res/layout/unified_search_footer.xml
Executable file
|
@ -0,0 +1,48 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!--
|
||||
Nextcloud Android client application
|
||||
|
||||
Copyright (C) 2020 Tobias Kaminsky
|
||||
Copyright (C) 2020 Nextcloud
|
||||
|
||||
This program is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
License as published by the Free Software Foundation; either
|
||||
version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public
|
||||
License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/unified_search_footer_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/min_list_item_size"
|
||||
android:baselineAligned="false"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<View
|
||||
android:layout_width="@dimen/standard_list_item_size"
|
||||
android:layout_height="@dimen/standard_list_item_size"
|
||||
android:layout_marginStart="@dimen/zero"
|
||||
android:layout_marginEnd="@dimen/standard_quarter_padding" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:text="@string/load_more_results"
|
||||
android:textColor="@color/secondary_text_color"
|
||||
tools:text="Load more results">
|
||||
|
||||
</TextView>
|
||||
</LinearLayout>
|
||||
|
|
@ -32,8 +32,7 @@
|
|||
android:layout_width="@dimen/standard_list_item_size"
|
||||
android:layout_height="@dimen/standard_list_item_size"
|
||||
android:layout_marginStart="@dimen/zero"
|
||||
android:layout_marginEnd="@dimen/standard_quarter_padding"
|
||||
android:layout_marginBottom="@dimen/standard_padding">
|
||||
android:layout_marginEnd="@dimen/standard_quarter_padding">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/thumbnail_layout"
|
||||
|
|
|
@ -991,4 +991,5 @@
|
|||
<string name="write_email">Send email</string>
|
||||
<string name="no_actions">No actions for this user</string>
|
||||
<string name="search_error">Error getting search results</string>
|
||||
<string name="load_more_results">Load more results</string>
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue