Unified search: basic "Load more" functionality

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>
This commit is contained in:
Álvaro Brey Vilas 2021-09-20 17:59:18 +02:00
parent 02d133a1c7
commit e8feb412a4
No known key found for this signature in database
GPG key ID: 2585783189A62105
12 changed files with 229 additions and 61 deletions

View file

@ -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)
}
}
}

View file

@ -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))
}
}

View file

@ -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,19 +69,24 @@ 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) {
return when (viewType) {
VIEW_TYPE_HEADER -> {
val binding = UnifiedSearchHeaderBinding.inflate(
LayoutInflater.from(
context
),
parent,
false
LayoutInflater.from(context), parent, false
)
UnifiedSearchHeaderViewHolder(binding, context)
} else {
}
VIEW_TYPE_FOOTER -> {
val binding = UnifiedSearchFooterBinding.inflate(
LayoutInflater.from(context), parent, false
)
UnifiedSearchFooterViewHolder(binding, context, listInterface)
}
else -> {
val binding = UnifiedSearchItemBinding.inflate(
LayoutInflater.from(
context
@ -85,22 +104,24 @@ class UnifiedSearchListAdapter(
)
}
}
}
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,20 +146,21 @@ 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> ->
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.key == FILES_PROVIDER_ID -> -1
o2.key == FILES_PROVIDER_ID -> 1
o1.providerID == FILES_PROVIDER_ID -> -1
o2.providerID == FILES_PROVIDER_ID -> 1
else -> 0
}
}
list = results.asSequence().sortedWith(comparator).map { it.value }.toList()
// TODO only update where needed
notifyDataSetChanged()
}
init {

View file

@ -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

View file

@ -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)
}

View file

@ -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
)
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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
}

View 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>

View file

@ -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"

View file

@ -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>