Эх сурвалжийг харах

Unified search: basic "Load more" functionality

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>
Álvaro Brey Vilas 3 жил өмнө
parent
commit
e8feb412a4

+ 41 - 0
src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchFooterViewHolder.kt

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

+ 2 - 3
src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchHeaderViewHolder.kt

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

+ 67 - 45
src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt

@@ -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(
-                    context
-                ),
-                parent,
-                false
-            )
-            UnifiedSearchHeaderViewHolder(binding, context)
-        } else {
-            val binding = UnifiedSearchItemBinding.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
-            )
-            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()

+ 8 - 2
src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt

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

+ 2 - 0
src/main/java/com/owncloud/android/ui/interfaces/UnifiedSearchListInterface.kt

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

+ 10 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchRepository.kt

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

+ 3 - 2
src/main/java/com/owncloud/android/ui/unifiedsearch/SearchOnProviderTask.kt

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

+ 25 - 1
src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchRemoteRepository.kt

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

+ 21 - 6
src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt

@@ -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 - 0
src/main/res/layout/unified_search_footer.xml

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

+ 1 - 2
src/main/res/layout/unified_search_item.xml

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

+ 1 - 0
src/main/res/values/strings.xml

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