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"> - + - - - - - - - - - - - - + + + - + app:showAsAction="always" + tools:ignore="AlwaysShowAction" /> + + 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=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">