diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index 73132caf4..58cc5482a 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -105,7 +105,7 @@
line="388"
column="28"/>
@@ -767,7 +767,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -778,7 +778,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -789,7 +789,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -800,7 +800,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1196,7 +1196,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1207,7 +1207,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1372,7 +1372,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1383,7 +1383,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1394,7 +1394,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1405,7 +1405,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1416,7 +1416,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1427,7 +1427,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1438,7 +1438,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1449,7 +1449,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
@@ -1460,7 +1460,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1471,7 +1471,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1482,7 +1482,7 @@
errorLine2=" ~~~~~~~~~~~~">
@@ -1493,7 +1493,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
@@ -1504,7 +1504,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
@@ -1515,7 +1515,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1526,7 +1526,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
@@ -1537,7 +1537,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1548,7 +1548,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1559,7 +1559,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
@@ -1570,7 +1570,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1581,7 +1581,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1592,7 +1592,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1603,7 +1603,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1614,7 +1614,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1625,7 +1625,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1636,7 +1636,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1647,7 +1647,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
diff --git a/app/src/main/java/app/pachli/components/search/SearchActivity.kt b/app/src/main/java/app/pachli/components/search/SearchActivity.kt
index ed99e7378..07cbc59dd 100644
--- a/app/src/main/java/app/pachli/components/search/SearchActivity.kt
+++ b/app/src/main/java/app/pachli/components/search/SearchActivity.kt
@@ -25,6 +25,7 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.viewModels
+import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
@@ -70,6 +71,7 @@ import app.pachli.components.search.adapter.SearchPagerAdapter
import app.pachli.core.activity.BottomSheetActivity
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
+import app.pachli.core.common.extensions.toggleVisibility
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
import app.pachli.core.network.Server
@@ -98,7 +100,11 @@ import app.pachli.databinding.SearchOperatorDateDialogBinding
import app.pachli.databinding.SearchOperatorFromDialogBinding
import app.pachli.databinding.SearchOperatorWhereLocationDialogBinding
import com.github.michaelbull.result.get
+import com.google.android.material.badge.BadgeDrawable
+import com.google.android.material.badge.BadgeUtils
+import com.google.android.material.badge.ExperimentalBadgeUtils
import com.google.android.material.chip.Chip
+import com.google.android.material.color.MaterialColors
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointBackward
import com.google.android.material.datepicker.MaterialDatePicker
@@ -125,6 +131,9 @@ class SearchActivity :
private lateinit var searchView: SearchView
+ val showFilterIcon: Boolean
+ get() = viewModel.availableOperators.value.isNotEmpty()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
@@ -156,6 +165,7 @@ class SearchActivity :
* Binds the initial search operator chips UI and updates as the search
* operators change.
*/
+ @OptIn(ExperimentalBadgeUtils::class)
private fun bindOperators() {
val viewDataToChip: Map>, Chip> = mapOf(
DateOperatorViewData::class.java to binding.chipDate,
@@ -170,6 +180,18 @@ class SearchActivity :
WhereOperatorViewData::class.java to binding.chipWhere,
)
+ // Chips are initially hidden, toggled by the "filter" button
+ binding.chipsFilter.hide()
+ binding.chipsFilter2.hide()
+ binding.chipsFilter3.hide()
+
+ // Badge to draw on the filter button if any filters are active.
+ val filterBadgeDrawable = BadgeDrawable.create(this).apply {
+ text = "!"
+ backgroundColor = MaterialColors.getColor(binding.toolbar, com.google.android.material.R.attr.colorPrimary)
+ }
+ BadgeUtils.attachBadgeDrawable(filterBadgeDrawable, binding.toolbar, R.id.action_filter_search)
+
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
launch {
@@ -189,17 +211,28 @@ class SearchActivity :
}
}
+ launch {
+ viewModel.availableOperators.collectLatest {
+ invalidateOptionsMenu()
+ setSearchViewWidth(showFilterIcon)
+ }
+ }
+
launch {
viewModel.operatorViewData.collectLatest { operators ->
+ var showFilterBadgeDrawable = false
+
operators.forEach { viewData ->
viewDataToChip[viewData::class.java]?.let { chip ->
+ showFilterBadgeDrawable = showFilterBadgeDrawable or (viewData.operator.choice != null)
chip.isChecked = viewData.operator.choice != null
chip.setCloseIconVisible(viewData.operator.choice != null)
chip.text = viewData.chipLabel(this@SearchActivity)
}
-
- viewModel.search()
}
+
+ filterBadgeDrawable.setVisible(showFilterBadgeDrawable)
+ viewModel.search()
}
}
}
@@ -933,15 +966,35 @@ class SearchActivity :
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
super.onCreateMenu(menu, menuInflater)
menuInflater.inflate(R.menu.search_toolbar, menu)
+
+ menu.findItem(R.id.action_filter_search)?.apply {
+ icon = makeIcon(this@SearchActivity, GoogleMaterial.Icon.gmd_tune, IconicsSize.dp(20))
+ }
+
val searchViewMenuItem = menu.findItem(R.id.action_search)
searchViewMenuItem.expandActionView()
searchView = searchViewMenuItem.actionView as SearchView
bindSearchView()
}
+ override fun onPrepareMenu(menu: Menu) {
+ menu.findItem(R.id.action_filter_search)?.apply {
+ isVisible = showFilterIcon
+ }
+ return super.onPrepareMenu(menu)
+ }
+
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
- super.onMenuItemSelected(menuItem)
- return false
+ return when (menuItem.itemId) {
+ R.id.action_filter_search -> {
+ binding.chipsFilter.toggleVisibility()
+ binding.chipsFilter2.toggleVisibility()
+ binding.chipsFilter3.toggleVisibility()
+ true
+ }
+
+ else -> super.onMenuItemSelected(menuItem)
+ }
}
private fun getPageTitle(position: Int): CharSequence {
@@ -965,6 +1018,27 @@ class SearchActivity :
searchView.setIconifiedByDefault(false)
searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName))
+ setSearchViewWidth(showFilterIcon)
+
+ // Keep text that was entered also when switching to a different tab (before the search is executed)
+ searchView.setOnQueryTextListener(this)
+ searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false)
+
+ // Only focus if the query is empty. This ensures that if the user is returning
+ // to the search results after visiting a result the full list is available,
+ // instead of being obscured by the keyboard.
+ if (viewModel.currentQuery.isBlank()) searchView.requestFocus()
+ }
+
+ /**
+ * Compute and set the width of [searchView].
+ *
+ * @param showingFilterIcon True if the filter icon is showing and the width should
+ * be adjusted to account for this.
+ */
+ private fun setSearchViewWidth(showingFilterIcon: Boolean) {
+ if (!this::searchView.isInitialized) return
+
// SearchView has a bug. If it's displayed 'app:showAsAction="always"' it's too wide,
// pushing other icons (including the options menu '...' icon) off the edge of the
// screen.
@@ -986,20 +1060,11 @@ class SearchActivity :
// It appears to be impossible to override this behaviour on API level < 33.
//
// SearchView does allow you to specify the maximum width. So take the screen width,
- // subtract 48dp * 2 (for the menu icon and back icon on either side), convert to pixels,
- // and use that.
+ // subtract 48dp * iconCount (for the menu, filter, and back icons), convert to pixels, and use that.
+ val iconCount = if (showingFilterIcon) 3 else 2
val pxScreenWidth = resources.displayMetrics.widthPixels
- val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt()
+ val pxBuffer = ((48 * iconCount) * resources.displayMetrics.density).toInt()
searchView.maxWidth = pxScreenWidth - pxBuffer
-
- // Keep text that was entered also when switching to a different tab (before the search is executed)
- searchView.setOnQueryTextListener(this)
- searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false)
-
- // Only focus if the query is empty. This ensures that if the user is returning
- // to the search results after visiting a result the full list is available,
- // instead of being obscured by the keyboard.
- if (viewModel.currentQuery.isBlank()) searchView.requestFocus()
}
override fun onQueryTextSubmit(query: String?): Boolean {
diff --git a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt
index 3ab9a077b..1779e9297 100644
--- a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt
+++ b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt
@@ -36,6 +36,20 @@ import app.pachli.components.search.adapter.SearchPagingSourceFactory
import app.pachli.core.accounts.AccountManager
import app.pachli.core.data.repository.ServerRepository
import app.pachli.core.database.model.AccountEntity
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_FROM
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE
+import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE
import app.pachli.core.network.model.DeletedStatus
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status
@@ -47,7 +61,9 @@ import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
+import com.github.michaelbull.result.mapBoth
import dagger.hilt.android.lifecycle.HiltViewModel
+import io.github.z4kn4fein.semver.constraints.toConstraint
import javax.inject.Inject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@@ -118,6 +134,68 @@ class SearchViewModel @Inject constructor(
null,
)
+ /**
+ * Set of operators the server supports.
+ *
+ * Empty set if the server does not support any operators.
+ */
+ val availableOperators = serverRepository.flow.map { result ->
+ result.mapBoth(
+ { server ->
+ buildSet {
+ val constraint100 = ">=1.0.0".toConstraint()
+ val canHasMedia = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA, constraint100)
+ val canHasImage = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE, constraint100)
+ val canHasVideo = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO, constraint100)
+ val canHasAudio = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO, constraint100)
+ if (canHasMedia || canHasImage || canHasVideo || canHasAudio) {
+ add(HasMediaOperator())
+ }
+
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE, constraint100)) {
+ add(DateOperator())
+ }
+
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_FROM, constraint100)) {
+ add(FromOperator())
+ }
+
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE, constraint100)) {
+ add(LanguageOperator())
+ }
+
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK, constraint100)) {
+ add(HasLinkOperator())
+ }
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED, constraint100)) {
+ add(HasEmbedOperator())
+ }
+
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL, constraint100)) {
+ add(HasPollOperator())
+ }
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY, constraint100)) {
+ add(IsReplyOperator())
+ }
+ if (server.can(ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE, constraint100)) {
+ add(IsSensitiveOperator())
+ }
+
+ val canInLibrary = server.can(ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY, constraint100)
+ val canInPublic = server.can(ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC, constraint100)
+ if (canInLibrary || canInPublic) add(WhereOperator())
+ }
+ },
+ {
+ emptySet()
+ },
+ )
+ }.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5000),
+ emptySet(),
+ )
+
private val loadedStatuses: MutableList = mutableListOf()
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
@@ -162,7 +240,7 @@ class SearchViewModel @Inject constructor(
* with [viewData].
*/
fun replaceOperator(viewData: SearchOperatorViewData) = _operatorViewData.update { operators ->
- operators.find { it.javaClass == viewData.javaClass }?.let { operators - it + viewData } ?: (operators + viewData)
+ operators.find { it.javaClass == viewData.javaClass }?.let { operators - it + viewData } ?: operators
}
fun search() {
diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml
index aaf235457..c05e45a14 100644
--- a/app/src/main/res/layout/activity_search.xml
+++ b/app/src/main/res/layout/activity_search.xml
@@ -21,100 +21,141 @@
app:layout_scrollFlags="scroll|snap|enterAlways"
app:navigationIcon="?attr/homeAsUpIndicator" />
-
+ android:scrollbars="none">
-
+ android:paddingStart="?listPreferredItemPaddingStart"
+ android:paddingEnd="?listPreferredItemPaddingEnd"
+ app:singleLine="true"
+ android:animateLayoutChanges="true">
-
+
+
+
+
+
+
+
+
+
+
+
+
+ android:paddingStart="?listPreferredItemPaddingStart"
+ android:paddingEnd="?listPreferredItemPaddingEnd"
+ app:singleLine="true"
+ android:animateLayoutChanges="true">
-
+
+
+
+
+
+
+
+
+
+
+
+
+ android:paddingStart="?listPreferredItemPaddingStart"
+ android:paddingEnd="?listPreferredItemPaddingEnd"
+ app:singleLine="true"
+ android:animateLayoutChanges="true">
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 23e349bfb..0c5b8fef0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -165,6 +165,7 @@
Accept
Reject
Search
+ Filter search
Drafts
Scheduled posts
Post visibility
diff --git a/core/common/src/main/kotlin/app/pachli/core/common/extensions/ViewExtensions.kt b/core/common/src/main/kotlin/app/pachli/core/common/extensions/ViewExtensions.kt
index 8ae5f67fd..f61be872e 100644
--- a/core/common/src/main/kotlin/app/pachli/core/common/extensions/ViewExtensions.kt
+++ b/core/common/src/main/kotlin/app/pachli/core/common/extensions/ViewExtensions.kt
@@ -30,3 +30,11 @@ fun View.hide() {
fun View.visible(visible: Boolean, or: Int = View.GONE) {
this.visibility = if (visible) View.VISIBLE else or
}
+
+fun View.toggleVisibility() {
+ when (this.visibility) {
+ View.GONE -> this.show()
+ View.INVISIBLE -> this.show()
+ View.VISIBLE -> this.hide()
+ }
+}
diff --git a/core/designsystem/lint-baseline.xml b/core/designsystem/lint-baseline.xml
index 7fb106162..749ba7ba0 100644
--- a/core/designsystem/lint-baseline.xml
+++ b/core/designsystem/lint-baseline.xml
@@ -8,7 +8,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">