feat: Provide a UI to edit different search operators (#829)
Mastodon supports in-query search operators, such as `has:image`, `language:en`, or `in:library`. Previously the user had to enter them in to the query directly. This provides a chip-based UI that allows the user to set values for these operators. ## Server - Add new search capabilities to record the faceted search features the server reports. - Update definitions for Mastodon, Friendica, and GoToSocial to specify which versions of the operations they support. ## SearchOperator / SearchOperatorViewData - Represents each supported operator and associated viewdata. ## SearchActivity / activity_search.xml - Conditionally display a chip for each facet depending on the server's level of support. - Implement the UI for each chip. They display dialogs of varying levels of complexity depending on the underlying operation. ## FragmentSearch - Display the progress as a LinearProgressIndicator instead of an indeterminate ProgressBar. This makes it more visible under the search facets.
This commit is contained in:
parent
e063ae69e6
commit
71e006b0d2
@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
package app.pachli.components.search
|
package app.pachli.components.search
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.SearchManager
|
import android.app.SearchManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@ -24,24 +25,105 @@ import android.view.Menu
|
|||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import app.pachli.R
|
import app.pachli.R
|
||||||
|
import app.pachli.components.compose.ComposeAutoCompleteAdapter
|
||||||
|
import app.pachli.components.search.SearchOperator.DateOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.DateOperator.DateRange
|
||||||
|
import app.pachli.components.search.SearchOperator.FromOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.FromOperator.FromKind.FromAccount
|
||||||
|
import app.pachli.components.search.SearchOperator.FromOperator.FromKind.FromMe
|
||||||
|
import app.pachli.components.search.SearchOperator.HasEmbedOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasEmbedOperator.EmbedKind
|
||||||
|
import app.pachli.components.search.SearchOperator.HasLinkOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasLinkOperator.LinkKind
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.HasMedia
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.NoMedia
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.SpecificMedia
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.MediaKind
|
||||||
|
import app.pachli.components.search.SearchOperator.HasPollOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.IsReplyOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.IsReplyOperator.ReplyKind
|
||||||
|
import app.pachli.components.search.SearchOperator.IsSensitiveOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.IsSensitiveOperator.SensitiveKind
|
||||||
|
import app.pachli.components.search.SearchOperator.LanguageOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.WhereOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.WhereOperator.WhereLocation
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.DateOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.FromOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.HasEmbedOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.HasLinkOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.HasMediaOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.HasPollOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.IsReplyOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.IsSensitiveOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.LanguageOperatorViewData
|
||||||
|
import app.pachli.components.search.SearchOperatorViewData.WhereOperatorViewData
|
||||||
import app.pachli.components.search.adapter.SearchPagerAdapter
|
import app.pachli.components.search.adapter.SearchPagerAdapter
|
||||||
import app.pachli.core.activity.BottomSheetActivity
|
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.viewBinding
|
import app.pachli.core.common.extensions.viewBinding
|
||||||
|
import app.pachli.core.common.extensions.visible
|
||||||
|
import app.pachli.core.network.Server
|
||||||
|
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.preferences.PrefKeys
|
import app.pachli.core.preferences.PrefKeys
|
||||||
|
import app.pachli.core.ui.extensions.await
|
||||||
|
import app.pachli.core.ui.extensions.awaitSingleChoiceItem
|
||||||
import app.pachli.core.ui.extensions.reduceSwipeSensitivity
|
import app.pachli.core.ui.extensions.reduceSwipeSensitivity
|
||||||
|
import app.pachli.core.ui.makeIcon
|
||||||
import app.pachli.databinding.ActivitySearchBinding
|
import app.pachli.databinding.ActivitySearchBinding
|
||||||
|
import app.pachli.databinding.SearchOperatorAttachmentDialogBinding
|
||||||
|
import app.pachli.databinding.SearchOperatorFromDialogBinding
|
||||||
|
import app.pachli.databinding.SearchOperatorWhereLocationDialogBinding
|
||||||
|
import com.github.michaelbull.result.get
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import com.google.android.material.datepicker.CalendarConstraints
|
||||||
|
import com.google.android.material.datepicker.DateValidatorPointBackward
|
||||||
|
import com.google.android.material.datepicker.MaterialDatePicker
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
import com.mikepenz.iconics.IconicsSize
|
||||||
|
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTextListener {
|
class SearchActivity :
|
||||||
|
BottomSheetActivity(),
|
||||||
|
MenuProvider,
|
||||||
|
SearchView.OnQueryTextListener,
|
||||||
|
ComposeAutoCompleteAdapter.AutocompletionProvider {
|
||||||
private val viewModel: SearchViewModel by viewModels()
|
private val viewModel: SearchViewModel by viewModels()
|
||||||
|
|
||||||
private val binding by viewBinding(ActivitySearchBinding::inflate)
|
private val binding by viewBinding(ActivitySearchBinding::inflate)
|
||||||
|
|
||||||
|
private lateinit var searchView: SearchView
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
@ -53,6 +135,7 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||||||
}
|
}
|
||||||
addMenuProvider(this)
|
addMenuProvider(this)
|
||||||
setupPages()
|
setupPages()
|
||||||
|
bindOperators()
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,12 +146,741 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||||||
val enableSwipeForTabs = sharedPreferencesRepository.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
|
val enableSwipeForTabs = sharedPreferencesRepository.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
|
||||||
binding.pages.isUserInputEnabled = enableSwipeForTabs
|
binding.pages.isUserInputEnabled = enableSwipeForTabs
|
||||||
|
|
||||||
TabLayoutMediator(binding.tabs, binding.pages) {
|
TabLayoutMediator(binding.tabs, binding.pages) { tab, position ->
|
||||||
tab, position ->
|
|
||||||
tab.text = getPageTitle(position)
|
tab.text = getPageTitle(position)
|
||||||
}.attach()
|
}.attach()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the initial search operator chips UI and updates as the search
|
||||||
|
* operators change.
|
||||||
|
*/
|
||||||
|
private fun bindOperators() {
|
||||||
|
val viewDataToChip: Map<Class<out SearchOperatorViewData<SearchOperator>>, Chip> = mapOf(
|
||||||
|
DateOperatorViewData::class.java to binding.chipDate,
|
||||||
|
FromOperatorViewData::class.java to binding.chipFrom,
|
||||||
|
HasEmbedOperatorViewData::class.java to binding.chipHasEmbed,
|
||||||
|
HasLinkOperatorViewData::class.java to binding.chipHasLink,
|
||||||
|
HasMediaOperatorViewData::class.java to binding.chipHasMedia,
|
||||||
|
HasPollOperatorViewData::class.java to binding.chipHasPoll,
|
||||||
|
IsReplyOperatorViewData::class.java to binding.chipIsReply,
|
||||||
|
IsSensitiveOperatorViewData::class.java to binding.chipIsSensitive,
|
||||||
|
LanguageOperatorViewData::class.java to binding.chipLanguage,
|
||||||
|
WhereOperatorViewData::class.java to binding.chipWhere,
|
||||||
|
)
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
launch {
|
||||||
|
viewModel.server.collectLatest {
|
||||||
|
// Ignore errors for the moment
|
||||||
|
val server = it?.get() ?: return@collectLatest
|
||||||
|
bindDateChip(server)
|
||||||
|
bindFromChip(server)
|
||||||
|
bindHasMediaChip(server)
|
||||||
|
bindHasEmbedChip(server)
|
||||||
|
bindHasLinkChip(server)
|
||||||
|
bindHasPollChip(server)
|
||||||
|
bindIsReplyChip(server)
|
||||||
|
bindIsSensitiveChip(server)
|
||||||
|
bindLanguageChip(server)
|
||||||
|
bindWhereChip(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.operatorViewData.collectLatest { operators ->
|
||||||
|
operators.forEach { viewData ->
|
||||||
|
viewDataToChip[viewData::class.java]?.let { chip ->
|
||||||
|
chip.isChecked = viewData.operator.choice != null
|
||||||
|
chip.setCloseIconVisible(viewData.operator.choice != null)
|
||||||
|
chip.text = viewData.chipLabel(this@SearchActivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binds the chip for [HasMediaOperatorViewData]. */
|
||||||
|
private fun bindHasMediaChip(server: Server) {
|
||||||
|
val constraint = ">=1.0.0".toConstraint()
|
||||||
|
val canHasMedia = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA, constraint)
|
||||||
|
val canHasImage = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE, constraint)
|
||||||
|
val canHasVideo = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO, constraint)
|
||||||
|
val canHasAudio = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO, constraint)
|
||||||
|
|
||||||
|
// Entire chip is hidden if there is no support for filtering by any kind of media.
|
||||||
|
if (!canHasMedia && !canHasImage && !canHasVideo && !canHasAudio) {
|
||||||
|
binding.chipHasMedia.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipHasMedia.show()
|
||||||
|
|
||||||
|
binding.chipHasMedia.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(HasMediaOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chipHasMedia.setOnClickListener {
|
||||||
|
binding.chipHasMedia.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val dialogBinding = SearchOperatorAttachmentDialogBinding.inflate(layoutInflater, null, false)
|
||||||
|
|
||||||
|
with(dialogBinding) {
|
||||||
|
// Disable individual groups/headings based on the type of media that
|
||||||
|
// searches can be filtered by
|
||||||
|
titleMedia.visible(canHasMedia)
|
||||||
|
chipgroupMedia.visible(canHasMedia)
|
||||||
|
mediaDivider.visible(canHasMedia && (canHasImage || canHasVideo || canHasAudio))
|
||||||
|
|
||||||
|
titleImage.visible(canHasImage)
|
||||||
|
chipgroupImages.visible(canHasImage)
|
||||||
|
|
||||||
|
titleVideo.visible(canHasVideo)
|
||||||
|
chipgroupVideo.visible(canHasVideo)
|
||||||
|
|
||||||
|
titleAudio.visible(canHasAudio)
|
||||||
|
chipgroupAudio.visible(canHasAudio)
|
||||||
|
|
||||||
|
// Turning on "No media" unchecks the images, video, and audio chips
|
||||||
|
chipNoMedia.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
if (!isChecked) return@setOnCheckedChangeListener
|
||||||
|
|
||||||
|
chipgroupImages.clearCheck()
|
||||||
|
chipgroupVideo.clearCheck()
|
||||||
|
chipgroupAudio.clearCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning on "With media" unchecks any more specific "With..." chips,
|
||||||
|
// as they're superfluous
|
||||||
|
chipHasMedia.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
if (!isChecked) return@setOnCheckedChangeListener
|
||||||
|
|
||||||
|
chipHasImage.isChecked = false
|
||||||
|
chipHasVideo.isChecked = false
|
||||||
|
chipHasAudio.isChecked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear chipGroupMedia if one of the more specific chips is clicked.
|
||||||
|
// Clicking a specific "At least one" button always clears chipGroupMedia,
|
||||||
|
// as that's a more specific choice.
|
||||||
|
//
|
||||||
|
// Clicking a specific "None" button only clears chipGroupMedia if chipGroupMedia
|
||||||
|
// is also "None"; chipGroupMedia set to "At least one" with a specific media
|
||||||
|
// choice set to "None" is a valid user choice.
|
||||||
|
chipgroupImages.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||||
|
if (checkedIds.contains(R.id.chip_has_image) ||
|
||||||
|
(checkedIds.contains(R.id.chip_no_image) && chipgroupMedia.checkedChipId == R.id.chip_no_media)
|
||||||
|
) {
|
||||||
|
chipgroupMedia.clearCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chipgroupVideo.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||||
|
if (checkedIds.contains(R.id.chip_has_video) ||
|
||||||
|
(checkedIds.contains(R.id.chip_no_video) && chipgroupMedia.checkedChipId == R.id.chip_no_media)
|
||||||
|
) {
|
||||||
|
chipgroupMedia.clearCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chipgroupAudio.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||||
|
if (checkedIds.contains(R.id.chip_has_audio) ||
|
||||||
|
(checkedIds.contains(R.id.chip_no_audio) && chipgroupMedia.checkedChipId == R.id.chip_no_media)
|
||||||
|
) {
|
||||||
|
chipgroupMedia.clearCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise the UI from the existing operator
|
||||||
|
val choice = viewModel.getOperator<HasMediaOperator>()?.choice
|
||||||
|
|
||||||
|
choice?.let { option ->
|
||||||
|
when (option) {
|
||||||
|
NoMedia -> dialogBinding.chipNoMedia.isChecked = true
|
||||||
|
is HasMedia -> {
|
||||||
|
dialogBinding.chipHasMedia.isChecked = true
|
||||||
|
|
||||||
|
option.exclude.forEach { exceptMedia ->
|
||||||
|
when (exceptMedia) {
|
||||||
|
MediaKind.IMAGE -> dialogBinding.chipNoImage.isChecked = true
|
||||||
|
MediaKind.VIDEO -> dialogBinding.chipNoVideo.isChecked = true
|
||||||
|
MediaKind.AUDIO -> dialogBinding.chipNoAudio.isChecked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SpecificMedia -> {
|
||||||
|
option.include.forEach { withMedia ->
|
||||||
|
when (withMedia) {
|
||||||
|
MediaKind.IMAGE -> dialogBinding.chipHasImage.isChecked = true
|
||||||
|
MediaKind.VIDEO -> dialogBinding.chipHasVideo.isChecked = true
|
||||||
|
MediaKind.AUDIO -> dialogBinding.chipHasAudio.isChecked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
option.exclude.forEach { exceptMedia ->
|
||||||
|
when (exceptMedia) {
|
||||||
|
MediaKind.IMAGE -> dialogBinding.chipNoImage.isChecked = true
|
||||||
|
MediaKind.VIDEO -> dialogBinding.chipNoVideo.isChecked = true
|
||||||
|
MediaKind.AUDIO -> dialogBinding.chipNoAudio.isChecked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val button = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setView(dialogBinding.root)
|
||||||
|
.setTitle(R.string.search_operator_attachment_dialog_title)
|
||||||
|
.create()
|
||||||
|
.await(android.R.string.ok, android.R.string.cancel)
|
||||||
|
|
||||||
|
if (button == AlertDialog.BUTTON_POSITIVE) {
|
||||||
|
val option = if (dialogBinding.chipNoMedia.isChecked) {
|
||||||
|
NoMedia
|
||||||
|
} else {
|
||||||
|
if (dialogBinding.chipHasMedia.isChecked) {
|
||||||
|
val except = buildList {
|
||||||
|
dialogBinding.chipNoImage.isChecked && add(MediaKind.IMAGE)
|
||||||
|
dialogBinding.chipNoVideo.isChecked && add(MediaKind.VIDEO)
|
||||||
|
dialogBinding.chipNoAudio.isChecked && add(MediaKind.AUDIO)
|
||||||
|
}
|
||||||
|
HasMedia(exclude = except)
|
||||||
|
} else {
|
||||||
|
val include = buildList {
|
||||||
|
dialogBinding.chipHasImage.isChecked && add(MediaKind.IMAGE)
|
||||||
|
dialogBinding.chipHasVideo.isChecked && add(MediaKind.VIDEO)
|
||||||
|
dialogBinding.chipHasAudio.isChecked && add(MediaKind.AUDIO)
|
||||||
|
}
|
||||||
|
val exclude = buildList {
|
||||||
|
dialogBinding.chipNoImage.isChecked && add(MediaKind.IMAGE)
|
||||||
|
dialogBinding.chipNoVideo.isChecked && add(MediaKind.VIDEO)
|
||||||
|
dialogBinding.chipNoAudio.isChecked && add(MediaKind.AUDIO)
|
||||||
|
}
|
||||||
|
if (include.isEmpty() && exclude.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
SpecificMedia(include = include, exclude = exclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(HasMediaOperator(option)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindDateChip(server: Server) {
|
||||||
|
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE, ">=1.0.0".toConstraint())) {
|
||||||
|
binding.chipDate.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipDate.show()
|
||||||
|
|
||||||
|
binding.chipDate.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_date_range, IconicsSize.dp(24))
|
||||||
|
binding.chipDate.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(DateOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chipDate.setOnClickListener {
|
||||||
|
binding.chipDate.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val picker = MaterialDatePicker.Builder.dateRangePicker()
|
||||||
|
.setTitleText(R.string.search_operator_date_dialog_title)
|
||||||
|
.setCalendarConstraints(
|
||||||
|
CalendarConstraints.Builder()
|
||||||
|
.setValidator(DateValidatorPointBackward.now())
|
||||||
|
// Default behaviour is to show two months, with the current month
|
||||||
|
// at the top. This wastes space, as the user can't select beyond
|
||||||
|
// the current month, so open one month earlier to show this month
|
||||||
|
// and the previous month on screen.
|
||||||
|
.setOpenAt(
|
||||||
|
LocalDateTime.now().minusMonths(1).toInstant(ZoneOffset.UTC).toEpochMilli(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.await(supportFragmentManager, "dateRangePicker")
|
||||||
|
|
||||||
|
picker ?: return@launch
|
||||||
|
|
||||||
|
val after = Instant.ofEpochMilli(picker.first).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val before = Instant.ofEpochMilli(picker.second).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||||
|
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(DateOperator(DateRange(after, before))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFromChip(server: Server) {
|
||||||
|
val canFrom = server.can(ORG_JOINMASTODON_SEARCH_QUERY_FROM, ">=1.0.0".toConstraint())
|
||||||
|
val canFromMe = server.can(ORG_JOINMASTODON_SEARCH_QUERY_FROM, ">=1.1.0".toConstraint())
|
||||||
|
if (!canFrom) {
|
||||||
|
binding.chipFrom.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipFrom.show()
|
||||||
|
|
||||||
|
binding.chipFrom.chipIcon = makeIcon(this@SearchActivity, GoogleMaterial.Icon.gmd_person_search, IconicsSize.dp(24))
|
||||||
|
binding.chipFrom.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(FromOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chipFrom.setOnClickListener {
|
||||||
|
binding.chipFrom.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val dialogBinding = SearchOperatorFromDialogBinding.inflate(layoutInflater, null, false)
|
||||||
|
|
||||||
|
dialogBinding.radioMe.visible(canFromMe)
|
||||||
|
dialogBinding.radioIgnoreMe.visible(canFromMe)
|
||||||
|
|
||||||
|
// Initialise the UI from the existing operator
|
||||||
|
when (val choice = viewModel.getOperator<FromOperator>()?.choice) {
|
||||||
|
null -> dialogBinding.radioGroup.check(R.id.radioAll)
|
||||||
|
is FromMe -> {
|
||||||
|
if (choice.ignore) {
|
||||||
|
dialogBinding.radioGroup.check(R.id.radioIgnoreMe)
|
||||||
|
} else {
|
||||||
|
dialogBinding.radioGroup.check(R.id.radioMe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is FromAccount -> {
|
||||||
|
if (choice.ignore) {
|
||||||
|
dialogBinding.radioGroup.check(R.id.radioIgnoreOtherAccount)
|
||||||
|
} else {
|
||||||
|
dialogBinding.radioGroup.check(R.id.radioOtherAccount)
|
||||||
|
}
|
||||||
|
dialogBinding.account.setText(choice.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogBinding.account.setAdapter(
|
||||||
|
ComposeAutoCompleteAdapter(
|
||||||
|
this@SearchActivity,
|
||||||
|
animateAvatar = false,
|
||||||
|
animateEmojis = false,
|
||||||
|
showBotBadge = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val dialog = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setView(dialogBinding.root)
|
||||||
|
.setTitle(R.string.search_operator_from_dialog_title)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
// Configure UI that needs to refer to the create dialog.
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
/**
|
||||||
|
* Updates UI state when the user clicks on options.
|
||||||
|
*
|
||||||
|
* - Adjusts focus on the account entry view as necessary
|
||||||
|
* - Disables the Ok button if one of the "other account" options is chosen and
|
||||||
|
* another account hasn't been entered.
|
||||||
|
*/
|
||||||
|
fun updateUi() {
|
||||||
|
val okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||||
|
|
||||||
|
val enabled = when (dialogBinding.radioGroup.checkedRadioButtonId) {
|
||||||
|
R.id.radioAll, R.id.radioMe, R.id.radioIgnoreMe -> {
|
||||||
|
dialogBinding.account.clearFocus()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.radioOtherAccount, R.id.radioIgnoreOtherAccount -> {
|
||||||
|
dialogBinding.account.requestFocus()
|
||||||
|
val text = dialogBinding.account.text
|
||||||
|
text.isNotBlank() && text.length >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
okButton.isEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogBinding.radioGroup.setOnCheckedChangeListener { _, _ -> updateUi() }
|
||||||
|
dialogBinding.account.doAfterTextChanged {
|
||||||
|
// Typing another account should set the other account radio button if one of
|
||||||
|
// the two has not already been set. This ensures the user doesn't enter text
|
||||||
|
// and tap OK before changing one of the radio buttons, losing their text.
|
||||||
|
val checkedId = dialogBinding.radioGroup.checkedRadioButtonId
|
||||||
|
if (checkedId != R.id.radioOtherAccount && checkedId != R.id.radioIgnoreOtherAccount) {
|
||||||
|
dialogBinding.radioGroup.check(R.id.radioOtherAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val button = dialog.await(android.R.string.ok, android.R.string.cancel)
|
||||||
|
|
||||||
|
if (button == AlertDialog.BUTTON_POSITIVE) {
|
||||||
|
val operator = when {
|
||||||
|
dialogBinding.radioMe.isChecked -> FromOperator(FromMe(ignore = false))
|
||||||
|
dialogBinding.radioIgnoreMe.isChecked -> FromOperator(FromMe(ignore = true))
|
||||||
|
dialogBinding.radioOtherAccount.isChecked -> FromOperator(
|
||||||
|
FromAccount(
|
||||||
|
account =
|
||||||
|
dialogBinding.account.text.toString(),
|
||||||
|
ignore = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
dialogBinding.radioIgnoreOtherAccount.isChecked -> FromOperator(
|
||||||
|
FromAccount(
|
||||||
|
account = dialogBinding.account.text.toString(),
|
||||||
|
ignore = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> FromOperator()
|
||||||
|
}
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(operator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindLanguageChip(server: Server) {
|
||||||
|
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE, ">=1.0.0".toConstraint())) {
|
||||||
|
binding.chipLanguage.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipLanguage.show()
|
||||||
|
|
||||||
|
binding.chipLanguage.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_translate, IconicsSize.dp(24))
|
||||||
|
binding.chipLanguage.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(LanguageOperator()))
|
||||||
|
}
|
||||||
|
val locales = listOf(null) + viewModel.locales.value
|
||||||
|
val displayLanguages = locales.map {
|
||||||
|
it?.displayLanguage ?: getString(R.string.search_operator_language_dialog_all)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chipLanguage.setOnClickListener {
|
||||||
|
binding.chipLanguage.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val choice = viewModel.getOperator<LanguageOperator>()?.choice
|
||||||
|
val index = locales.indexOf(choice)
|
||||||
|
|
||||||
|
val result = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setTitle(R.string.search_operator_language_dialog_title)
|
||||||
|
.awaitSingleChoiceItem(
|
||||||
|
displayLanguages,
|
||||||
|
index,
|
||||||
|
android.R.string.ok,
|
||||||
|
android.R.string.cancel,
|
||||||
|
)
|
||||||
|
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||||
|
viewModel.replaceOperator(
|
||||||
|
SearchOperatorViewData.from(LanguageOperator(locales[result.index])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// binding.chipLink
|
||||||
|
// empty operator (LinkOperator() here)
|
||||||
|
// options to string map
|
||||||
|
// Dialog title resource
|
||||||
|
private fun bindHasLinkChip(server: Server) {
|
||||||
|
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK, ">=1.0.0".toConstraint())) {
|
||||||
|
binding.chipHasLink.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipHasLink.show()
|
||||||
|
|
||||||
|
binding.chipHasLink.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(HasLinkOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = listOf(
|
||||||
|
null to R.string.search_operator_link_dialog_all,
|
||||||
|
LinkKind.LINKS_ONLY to R.string.search_operator_link_dialog_only,
|
||||||
|
LinkKind.NO_LINKS to R.string.search_operator_link_dialog_no_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayOptions = options.map { getString(it.second) }
|
||||||
|
|
||||||
|
binding.chipHasLink.setOnClickListener {
|
||||||
|
binding.chipHasLink.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val selectedOption = viewModel.getOperator<HasLinkOperator>()?.choice
|
||||||
|
val index = options.indexOfFirst { it.first == selectedOption }
|
||||||
|
|
||||||
|
val result = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setTitle(R.string.search_operator_link_dialog_title)
|
||||||
|
.awaitSingleChoiceItem(
|
||||||
|
displayOptions,
|
||||||
|
index,
|
||||||
|
android.R.string.ok,
|
||||||
|
android.R.string.cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||||
|
viewModel.replaceOperator(
|
||||||
|
SearchOperatorViewData.from(HasLinkOperator(options[result.index].first)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindHasEmbedChip(server: Server) {
|
||||||
|
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED, ">=1.0.0".toConstraint())) {
|
||||||
|
binding.chipHasEmbed.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipHasEmbed.show()
|
||||||
|
|
||||||
|
binding.chipHasEmbed.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_photo_size_select_actual, IconicsSize.dp(24))
|
||||||
|
binding.chipHasEmbed.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(HasEmbedOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = listOf(
|
||||||
|
null to R.string.search_operator_embed_dialog_all,
|
||||||
|
EmbedKind.EMBED_ONLY to R.string.search_operator_embed_dialog_only,
|
||||||
|
EmbedKind.NO_EMBED to R.string.search_operator_embed_dialog_no_embeds,
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayOptions = options.map { getString(it.second) }
|
||||||
|
|
||||||
|
binding.chipHasEmbed.setOnClickListener {
|
||||||
|
binding.chipHasEmbed.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val selectedOption = viewModel.getOperator<HasEmbedOperator>()?.choice
|
||||||
|
val index = options.indexOfFirst { it.first == selectedOption }
|
||||||
|
|
||||||
|
val result = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setTitle(R.string.search_operator_embed_dialog_title)
|
||||||
|
.awaitSingleChoiceItem(
|
||||||
|
displayOptions,
|
||||||
|
index,
|
||||||
|
android.R.string.ok,
|
||||||
|
android.R.string.cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||||
|
viewModel.replaceOperator(
|
||||||
|
SearchOperatorViewData.from(HasEmbedOperator(options[result.index].first)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindHasPollChip(server: Server) {
|
||||||
|
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL, ">=1.0.0".toConstraint())) {
|
||||||
|
binding.chipHasPoll.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipHasPoll.show()
|
||||||
|
|
||||||
|
binding.chipHasPoll.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_poll, IconicsSize.dp(24))
|
||||||
|
binding.chipHasPoll.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(HasPollOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = listOf(
|
||||||
|
null to R.string.search_operator_poll_dialog_all,
|
||||||
|
HasPollOperator.PollKind.POLLS_ONLY to R.string.search_operator_poll_dialog_only,
|
||||||
|
HasPollOperator.PollKind.NO_POLLS to R.string.search_operator_poll_dialog_no_polls,
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayOptions = options.map { getString(it.second) }
|
||||||
|
|
||||||
|
binding.chipHasPoll.setOnClickListener {
|
||||||
|
binding.chipHasPoll.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val choice = viewModel.getOperator<HasPollOperator>()?.choice
|
||||||
|
val index = options.indexOfFirst { it.first == choice }
|
||||||
|
|
||||||
|
val result = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setTitle(R.string.search_operator_poll_dialog_title)
|
||||||
|
.awaitSingleChoiceItem(
|
||||||
|
displayOptions,
|
||||||
|
index,
|
||||||
|
android.R.string.ok,
|
||||||
|
android.R.string.cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||||
|
viewModel.replaceOperator(
|
||||||
|
SearchOperatorViewData.from(HasPollOperator(options[result.index].first)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindIsReplyChip(server: Server) {
|
||||||
|
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY, ">=1.0.0".toConstraint())) {
|
||||||
|
binding.chipIsReply.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipIsReply.show()
|
||||||
|
|
||||||
|
binding.chipIsReply.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(IsReplyOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = listOf(
|
||||||
|
null to R.string.search_operator_replies_dialog_all,
|
||||||
|
ReplyKind.REPLIES_ONLY to R.string.search_operator_replies_dialog_replies_only,
|
||||||
|
ReplyKind.NO_REPLIES to R.string.search_operator_replies_dialog_no_replies,
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayOptions = options.map { getString(it.second) }
|
||||||
|
|
||||||
|
binding.chipIsReply.setOnClickListener {
|
||||||
|
binding.chipIsReply.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val choice = viewModel.getOperator<IsReplyOperator>()?.choice
|
||||||
|
val index = options.indexOfFirst { it.first == choice }
|
||||||
|
|
||||||
|
val result = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setTitle(R.string.search_operator_replies_dialog_title)
|
||||||
|
.awaitSingleChoiceItem(
|
||||||
|
displayOptions,
|
||||||
|
index,
|
||||||
|
android.R.string.ok,
|
||||||
|
android.R.string.cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||||
|
viewModel.replaceOperator(
|
||||||
|
SearchOperatorViewData.from(IsReplyOperator(options[result.index].first)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindIsSensitiveChip(server: Server) {
|
||||||
|
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE, ">=1.0.0".toConstraint())) {
|
||||||
|
binding.chipIsSensitive.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipIsSensitive.show()
|
||||||
|
|
||||||
|
binding.chipIsSensitive.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(IsSensitiveOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = listOf(
|
||||||
|
null to R.string.search_operator_sensitive_dialog_all,
|
||||||
|
SensitiveKind.SENSITIVE_ONLY to R.string.search_operator_sensitive_dialog_sensitive_only,
|
||||||
|
SensitiveKind.NO_SENSITIVE to R.string.search_operator_sensitive_dialog_no_sensitive,
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayOptions = options.map { getString(it.second) }
|
||||||
|
|
||||||
|
binding.chipIsSensitive.setOnClickListener {
|
||||||
|
binding.chipIsSensitive.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val choice = viewModel.getOperator<IsSensitiveOperator>()?.choice
|
||||||
|
val index = options.indexOfFirst { it.first == choice }
|
||||||
|
|
||||||
|
val result = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setTitle(R.string.search_operator_sensitive_dialog_title)
|
||||||
|
.awaitSingleChoiceItem(
|
||||||
|
displayOptions,
|
||||||
|
index,
|
||||||
|
android.R.string.ok,
|
||||||
|
android.R.string.cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||||
|
viewModel.replaceOperator(
|
||||||
|
SearchOperatorViewData.from(IsSensitiveOperator(options[result.index].first)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
private fun bindWhereChip(server: Server) {
|
||||||
|
val canInLibrary = server.can(ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY, ">=1.0.0".toConstraint())
|
||||||
|
val canInPublic = server.can(ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC, ">=1.0.0".toConstraint())
|
||||||
|
|
||||||
|
if (!canInLibrary && !canInPublic) {
|
||||||
|
binding.chipWhere.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.chipWhere.show()
|
||||||
|
|
||||||
|
binding.chipWhere.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_find_in_page, IconicsSize.dp(24))
|
||||||
|
binding.chipWhere.setOnCloseIconClickListener {
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(WhereOperator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chipWhere.setOnClickListener {
|
||||||
|
binding.chipWhere.toggle()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val choice = viewModel.getOperator<WhereOperator>()?.choice
|
||||||
|
|
||||||
|
val dialogBinding = SearchOperatorWhereLocationDialogBinding.inflate(layoutInflater, null, false)
|
||||||
|
|
||||||
|
dialogBinding.whereLibraryTitle.visible(canInLibrary)
|
||||||
|
dialogBinding.whereLibraryDescription.visible(canInLibrary)
|
||||||
|
|
||||||
|
dialogBinding.wherePublicTitle.visible(canInPublic)
|
||||||
|
dialogBinding.wherePublicDescription.visible(canInPublic)
|
||||||
|
|
||||||
|
dialogBinding.whereAllDialogGroup.check(
|
||||||
|
when (choice) {
|
||||||
|
null -> R.id.where_all_title
|
||||||
|
WhereLocation.LIBRARY -> R.id.where_library_title
|
||||||
|
WhereLocation.PUBLIC -> R.id.where_public_title
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dispatch touch events on the descriptions to the view with
|
||||||
|
// the radiobutton. Dispatching touch events ensures behaviour
|
||||||
|
// -- like the ripple that appears if the view is long-pressed --
|
||||||
|
// is retained.
|
||||||
|
dialogBinding.whereAllDescription.setOnTouchListener { _, event ->
|
||||||
|
dialogBinding.whereAllTitle.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
dialogBinding.whereLibraryDescription.setOnTouchListener { _, event ->
|
||||||
|
dialogBinding.whereLibraryTitle.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
dialogBinding.wherePublicDescription.setOnTouchListener { _, event ->
|
||||||
|
dialogBinding.wherePublicTitle.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
val button = AlertDialog.Builder(this@SearchActivity)
|
||||||
|
.setView(dialogBinding.root)
|
||||||
|
.setTitle(R.string.search_operator_where_dialog_title)
|
||||||
|
.create()
|
||||||
|
.await(android.R.string.ok, android.R.string.cancel)
|
||||||
|
|
||||||
|
if (button == AlertDialog.BUTTON_POSITIVE) {
|
||||||
|
val location = when (dialogBinding.whereAllDialogGroup.checkedRadioButtonId) {
|
||||||
|
R.id.where_library_title -> WhereLocation.LIBRARY
|
||||||
|
R.id.where_public_title -> WhereLocation.PUBLIC
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
viewModel.replaceOperator(SearchOperatorViewData.from(WhereOperator(location)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
@ -79,8 +891,8 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||||||
menuInflater.inflate(R.menu.search_toolbar, menu)
|
menuInflater.inflate(R.menu.search_toolbar, menu)
|
||||||
val searchViewMenuItem = menu.findItem(R.id.action_search)
|
val searchViewMenuItem = menu.findItem(R.id.action_search)
|
||||||
searchViewMenuItem.expandActionView()
|
searchViewMenuItem.expandActionView()
|
||||||
val searchView = searchViewMenuItem.actionView as SearchView
|
searchView = searchViewMenuItem.actionView as SearchView
|
||||||
setupSearchView(searchView)
|
bindSearchView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
@ -99,12 +911,13 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||||||
|
|
||||||
private fun handleIntent(intent: Intent) {
|
private fun handleIntent(intent: Intent) {
|
||||||
if (Intent.ACTION_SEARCH == intent.action) {
|
if (Intent.ACTION_SEARCH == intent.action) {
|
||||||
|
searchView.clearFocus()
|
||||||
viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY).orEmpty()
|
viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY).orEmpty()
|
||||||
viewModel.search(viewModel.currentQuery)
|
viewModel.search(viewModel.currentQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupSearchView(searchView: SearchView) {
|
private fun bindSearchView() {
|
||||||
searchView.setIconifiedByDefault(false)
|
searchView.setIconifiedByDefault(false)
|
||||||
searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName))
|
searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName))
|
||||||
|
|
||||||
@ -142,7 +955,7 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||||||
// Only focus if the query is empty. This ensures that if the user is returning
|
// 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,
|
// to the search results after visiting a result the full list is available,
|
||||||
// instead of being obscured by the keyboard.
|
// instead of being obscured by the keyboard.
|
||||||
if (searchView.query.isBlank()) searchView.requestFocus()
|
if (viewModel.currentSearchFieldContent?.isBlank() == true) searchView.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||||
@ -154,4 +967,9 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seach for autocomplete suggestions
|
||||||
|
override suspend fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||||
|
return viewModel.searchAccountAutocompleteSuggestions(token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
242
app/src/main/java/app/pachli/components/search/SearchOperator.kt
Normal file
242
app/src/main/java/app/pachli/components/search/SearchOperator.kt
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Pachli Association
|
||||||
|
*
|
||||||
|
* This file is a part of Pachli.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Pachli 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 General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package app.pachli.components.search
|
||||||
|
|
||||||
|
import app.pachli.BuildConfig
|
||||||
|
import app.pachli.util.modernLanguageCode
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Mastodon search operators. */
|
||||||
|
sealed interface SearchOperator {
|
||||||
|
/**
|
||||||
|
* The user's choice from the set of possible choices the operator supports.
|
||||||
|
*
|
||||||
|
* If null the user has not made a specific choice, and the operator's default
|
||||||
|
* should be used.
|
||||||
|
*/
|
||||||
|
val choice: Any?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Text to include in the search query if [choice] is non-null.
|
||||||
|
*/
|
||||||
|
fun query(): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `has:{media,image,video,audio}` operator.
|
||||||
|
*
|
||||||
|
* Mastodon does not let you create posts that have attached media and a poll
|
||||||
|
* or other attachment. But it will store and return statuses sent from other
|
||||||
|
* systems that do not have this restriction.
|
||||||
|
*
|
||||||
|
* @see HasEmbedOperator
|
||||||
|
* @see HasPollOperator
|
||||||
|
*/
|
||||||
|
data class HasMediaOperator(override val choice: HasMediaOption? = null) : SearchOperator {
|
||||||
|
enum class MediaKind(val q: String) {
|
||||||
|
IMAGE("image"),
|
||||||
|
VIDEO("video"),
|
||||||
|
AUDIO("audio"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The specific `has:{media,image,video,audio}` operator in use. */
|
||||||
|
sealed interface HasMediaOption {
|
||||||
|
/** Exclude posts that have any media attached.
|
||||||
|
*
|
||||||
|
* Equivalent to `-has:media`.
|
||||||
|
*/
|
||||||
|
data object NoMedia : HasMediaOption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include only posts that have any media attached, except posts that
|
||||||
|
* have media types in [exclude].
|
||||||
|
*
|
||||||
|
* Equivalent to `has:media`, with zero or more additional `-has:[exclude]`
|
||||||
|
* after.
|
||||||
|
*/
|
||||||
|
data class HasMedia(val exclude: List<MediaKind> = emptyList()) : HasMediaOption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include only posts that have [include] media attached, excluding posts
|
||||||
|
* that have [exclude] attached media.
|
||||||
|
*/
|
||||||
|
data class SpecificMedia(
|
||||||
|
val include: List<MediaKind> = emptyList(),
|
||||||
|
val exclude: List<MediaKind> = emptyList(),
|
||||||
|
) : HasMediaOption {
|
||||||
|
init {
|
||||||
|
// Check
|
||||||
|
// - with and without can't both be empty
|
||||||
|
// - with and without should not contain any shared elements
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
assert(!(include.isEmpty() && exclude.isEmpty()))
|
||||||
|
assert(include.intersect(exclude.toSet()).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query(): String? {
|
||||||
|
choice ?: return null
|
||||||
|
|
||||||
|
return when (choice) {
|
||||||
|
HasMediaOption.NoMedia -> ("-has:media")
|
||||||
|
is HasMediaOption.HasMedia -> buildList {
|
||||||
|
add("has:media")
|
||||||
|
choice.exclude.forEach { add("-has:${it.q}") }
|
||||||
|
}.joinToString(" ")
|
||||||
|
|
||||||
|
is HasMediaOption.SpecificMedia -> buildList {
|
||||||
|
choice.include.forEach { add("has:${it.q}") }
|
||||||
|
choice.exclude.forEach { add("-has:${it.q}") }
|
||||||
|
}.joinToString(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The date-range operator. Creates `after:... before:...`. */
|
||||||
|
class DateOperator(override val choice: DateRange? = null) : SearchOperator {
|
||||||
|
/**
|
||||||
|
* The date range to search.
|
||||||
|
*
|
||||||
|
* @param startDate Earliest date to search (inclusive)
|
||||||
|
* @param endDate Latest date to search (inclusive)
|
||||||
|
*/
|
||||||
|
data class DateRange(val startDate: LocalDate, val endDate: LocalDate) {
|
||||||
|
// This class treats the date range as **inclusive** of the start and
|
||||||
|
// end dates, Mastodon's search treats the dates as exclusive, so the
|
||||||
|
// range must be expanded by one day in each direction when creating
|
||||||
|
// the search string.
|
||||||
|
fun fmt() = "after:${formatter.format(startDate.minusDays(1))} before:${formatter.format(endDate.plusDays(1))}"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.fmt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `from:...` operator. */
|
||||||
|
class FromOperator(override val choice: FromKind? = null) : SearchOperator {
|
||||||
|
/** The specific `from:...` operator in use. */
|
||||||
|
sealed interface FromKind {
|
||||||
|
val ignore: Boolean
|
||||||
|
val q: String
|
||||||
|
|
||||||
|
/** `from:me`, or `-from:me` if [ignore] is true. */
|
||||||
|
data class FromMe(override val ignore: Boolean) : FromKind {
|
||||||
|
override val q: String
|
||||||
|
get() = if (ignore) "-from:me" else "from:me"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `from:<account>` or `-from:<account>` if [ignore] is true.
|
||||||
|
*
|
||||||
|
* @param account The account name. Any leading `@` will be removed.
|
||||||
|
*/
|
||||||
|
data class FromAccount(val account: String, override val ignore: Boolean) : FromKind {
|
||||||
|
override val q: String
|
||||||
|
get() = if (ignore) "-from:${account.removePrefix("@")}" else "from:${account.removePrefix("@")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.q
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `has:embed` operator.
|
||||||
|
*
|
||||||
|
* @see HasMediaOperator
|
||||||
|
* @see HasPollOperator
|
||||||
|
*/
|
||||||
|
data class HasEmbedOperator(override val choice: EmbedKind? = null) : SearchOperator {
|
||||||
|
enum class EmbedKind(val q: String) {
|
||||||
|
EMBED_ONLY("has:embed"),
|
||||||
|
NO_EMBED("-has:embed"),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.q
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `language:...` operator.
|
||||||
|
*
|
||||||
|
* @param choice Restrict results to posts written in [Locale.modernLanguageCode].
|
||||||
|
*/
|
||||||
|
class LanguageOperator(override val choice: Locale? = null) : SearchOperator {
|
||||||
|
override fun query() = choice?.let { "language:${it.modernLanguageCode}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `has:link` operator. */
|
||||||
|
data class HasLinkOperator(override val choice: LinkKind? = null) : SearchOperator {
|
||||||
|
enum class LinkKind(val q: String) {
|
||||||
|
LINKS_ONLY("has:link"),
|
||||||
|
NO_LINKS("-has:link"),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.q
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `has:poll` operator.
|
||||||
|
*
|
||||||
|
* @see HasEmbedOperator
|
||||||
|
* @see HasMediaOperator
|
||||||
|
*/
|
||||||
|
data class HasPollOperator(override val choice: PollKind? = null) : SearchOperator {
|
||||||
|
enum class PollKind(val q: String) {
|
||||||
|
POLLS_ONLY("has:poll"),
|
||||||
|
NO_POLLS("-has:poll"),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.q
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `is:reply` operator. */
|
||||||
|
class IsReplyOperator(override val choice: ReplyKind? = null) : SearchOperator {
|
||||||
|
enum class ReplyKind(val q: String) {
|
||||||
|
REPLIES_ONLY("is:reply"),
|
||||||
|
NO_REPLIES("-is:reply"),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.q
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `is:sensitive` operator. */
|
||||||
|
class IsSensitiveOperator(override val choice: SensitiveKind? = null) : SearchOperator {
|
||||||
|
// (choice) {
|
||||||
|
enum class SensitiveKind(val q: String) {
|
||||||
|
SENSITIVE_ONLY("is:sensitive"),
|
||||||
|
NO_SENSITIVE("-is:sensitive"),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.q
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `in:...` operator. */
|
||||||
|
class WhereOperator(override val choice: WhereLocation? = null) : SearchOperator {
|
||||||
|
enum class WhereLocation(val q: String) {
|
||||||
|
LIBRARY("in:library"),
|
||||||
|
PUBLIC("in:public"),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query() = choice?.q
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,288 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Pachli Association
|
||||||
|
*
|
||||||
|
* This file is a part of Pachli.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Pachli 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 General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package app.pachli.components.search
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import app.pachli.R
|
||||||
|
import app.pachli.components.search.SearchOperator.DateOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.FromOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasEmbedOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasLinkOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.HasMedia
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.NoMedia
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.SpecificMedia
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator.MediaKind
|
||||||
|
import app.pachli.components.search.SearchOperator.HasPollOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.IsReplyOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.IsSensitiveOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.LanguageOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.WhereOperator
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for [SearchOperator] that includes additional information to show the
|
||||||
|
* operator as a chip in the UI.
|
||||||
|
*/
|
||||||
|
sealed interface SearchOperatorViewData<out T : SearchOperator> {
|
||||||
|
/** Underlying [SearchOperator]. */
|
||||||
|
val operator: T
|
||||||
|
|
||||||
|
/** @return The label to use on the chip for [operator]. */
|
||||||
|
fun chipLabel(context: Context): String
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** @return The correct [SearchOperatorViewData] for the [operator]. */
|
||||||
|
fun from(operator: SearchOperator) = when (operator) {
|
||||||
|
is HasMediaOperator -> HasMediaOperatorViewData(operator)
|
||||||
|
is DateOperator -> DateOperatorViewData(operator)
|
||||||
|
is FromOperator -> FromOperatorViewData(operator)
|
||||||
|
is HasEmbedOperator -> HasEmbedOperatorViewData(operator)
|
||||||
|
is LanguageOperator -> LanguageOperatorViewData(operator)
|
||||||
|
is HasLinkOperator -> HasLinkOperatorViewData(operator)
|
||||||
|
is HasPollOperator -> HasPollOperatorViewData(operator)
|
||||||
|
is IsReplyOperator -> IsReplyOperatorViewData(operator)
|
||||||
|
is IsSensitiveOperator -> IsSensitiveOperatorViewData(operator)
|
||||||
|
is WhereOperator -> WhereOperatorViewData(operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HasMediaOperatorViewData(override val operator: HasMediaOperator) : SearchOperatorViewData<HasMediaOperator> {
|
||||||
|
override fun chipLabel(context: Context): String {
|
||||||
|
fun MediaKind.label() = context.getString(this.stringResource)
|
||||||
|
|
||||||
|
return when (val choice = operator.choice) {
|
||||||
|
null -> context.getString(R.string.search_operator_attachment_all)
|
||||||
|
NoMedia -> context.getString(R.string.search_operator_attachment_no_media_label)
|
||||||
|
is HasMedia -> {
|
||||||
|
val exclude = choice.exclude
|
||||||
|
if (exclude.isEmpty()) {
|
||||||
|
context.getString(R.string.search_operator_attachment_has_media_label)
|
||||||
|
} else {
|
||||||
|
when (exclude.size) {
|
||||||
|
1 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_has_media_except_1_label_fmt,
|
||||||
|
exclude[0].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
2 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_has_media_except_2_label_fmt,
|
||||||
|
exclude[0].label(),
|
||||||
|
exclude[1].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> context.getString(
|
||||||
|
R.string.search_operator_attachment_has_media_except_3_label_fmt,
|
||||||
|
exclude[0].label(),
|
||||||
|
exclude[1].label(),
|
||||||
|
exclude[2].label(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SpecificMedia -> {
|
||||||
|
val include = choice.include
|
||||||
|
val exclude = choice.exclude
|
||||||
|
|
||||||
|
when {
|
||||||
|
// With and except
|
||||||
|
include.isNotEmpty() && exclude.isNotEmpty() -> when {
|
||||||
|
// Include 2, exclude 1
|
||||||
|
include.size == 2 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_include_2_exclude_1_fmt,
|
||||||
|
include[0].label(),
|
||||||
|
include[1].label(),
|
||||||
|
exclude[0].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Include 1, exclude 2
|
||||||
|
include.size == 1 && exclude.size == 2 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_include_1_exclude_2_fmt,
|
||||||
|
include[0].label(),
|
||||||
|
exclude[0].label(),
|
||||||
|
exclude[1].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Include 1, exclude 1
|
||||||
|
else -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_include_1_exclude_1_fmt,
|
||||||
|
include[0].label(),
|
||||||
|
exclude[0].label(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Include only
|
||||||
|
include.isNotEmpty() -> when (include.size) {
|
||||||
|
1 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_include_1_fmt,
|
||||||
|
include[0].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
2 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_include_2_fmt,
|
||||||
|
include[0].label(),
|
||||||
|
include[1].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_include_3_fmt,
|
||||||
|
include[0].label(),
|
||||||
|
include[1].label(),
|
||||||
|
include[2].label(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// exclude only
|
||||||
|
else -> when (exclude.size) {
|
||||||
|
1 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_exclude_1_fmt,
|
||||||
|
exclude[0].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
2 -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_exclude_2_fmt,
|
||||||
|
exclude[0].label(),
|
||||||
|
exclude[1].label(),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> context.getString(
|
||||||
|
R.string.search_operator_attachment_specific_media_exclude_3_fmt,
|
||||||
|
exclude[0].label(),
|
||||||
|
exclude[1].label(),
|
||||||
|
exclude[2].label(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val MediaKind.stringResource: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MediaKind.IMAGE -> R.string.search_operator_attachment_kind_image_label
|
||||||
|
MediaKind.VIDEO -> R.string.search_operator_attachment_kind_video_label
|
||||||
|
MediaKind.AUDIO -> R.string.search_operator_attachment_kind_audio_label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DateOperatorViewData(override val operator: DateOperator) : SearchOperatorViewData<DateOperator> {
|
||||||
|
private val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
|
||||||
|
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||||
|
null -> context.getString(R.string.search_operator_date_all)
|
||||||
|
else -> {
|
||||||
|
if (operator.choice.startDate == operator.choice.endDate) {
|
||||||
|
context.getString(
|
||||||
|
R.string.search_operator_date_checked_same_day,
|
||||||
|
formatter.format(operator.choice.startDate),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
context.getString(
|
||||||
|
R.string.search_operator_date_checked,
|
||||||
|
formatter.format(operator.choice.startDate),
|
||||||
|
formatter.format(operator.choice.endDate),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HasEmbedOperatorViewData(override val operator: HasEmbedOperator) : SearchOperatorViewData<HasEmbedOperator> {
|
||||||
|
override fun chipLabel(context: Context) = context.getString(
|
||||||
|
when (operator.choice) {
|
||||||
|
null -> R.string.search_operator_embed_all
|
||||||
|
HasEmbedOperator.EmbedKind.EMBED_ONLY -> R.string.search_operator_embed_only
|
||||||
|
HasEmbedOperator.EmbedKind.NO_EMBED -> R.string.search_operator_embed_no_embeds
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FromOperatorViewData(override val operator: FromOperator) : SearchOperatorViewData<FromOperator> {
|
||||||
|
override fun chipLabel(context: Context) = when (val choice = operator.choice) {
|
||||||
|
null -> context.getString(R.string.search_operator_from_all)
|
||||||
|
is FromOperator.FromKind.FromMe -> context.getString(
|
||||||
|
if (choice.ignore) R.string.search_operator_from_ignore_me else R.string.search_operator_from_me,
|
||||||
|
)
|
||||||
|
|
||||||
|
is FromOperator.FromKind.FromAccount -> context.getString(
|
||||||
|
if (choice.ignore) {
|
||||||
|
R.string.search_operator_from_ignore_account_fmt
|
||||||
|
} else {
|
||||||
|
R.string.search_operator_from_account_fmt
|
||||||
|
},
|
||||||
|
choice.account,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LanguageOperatorViewData(override val operator: LanguageOperator) : SearchOperatorViewData<LanguageOperator> {
|
||||||
|
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||||
|
null -> context.getString(R.string.search_operator_language_all)
|
||||||
|
else -> context.getString(
|
||||||
|
R.string.search_operator_language_checked_fmt,
|
||||||
|
operator.choice.displayLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HasLinkOperatorViewData(override val operator: HasLinkOperator) : SearchOperatorViewData<HasLinkOperator> {
|
||||||
|
override fun chipLabel(context: Context) = context.getString(
|
||||||
|
when (operator.choice) {
|
||||||
|
null -> R.string.search_operator_link_all
|
||||||
|
HasLinkOperator.LinkKind.LINKS_ONLY -> R.string.search_operator_link_only
|
||||||
|
HasLinkOperator.LinkKind.NO_LINKS -> R.string.search_operator_no_link
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HasPollOperatorViewData(override val operator: HasPollOperator) : SearchOperatorViewData<HasPollOperator> {
|
||||||
|
override fun chipLabel(context: Context) = context.getString(
|
||||||
|
when (operator.choice) {
|
||||||
|
null -> R.string.search_operator_poll_all
|
||||||
|
HasPollOperator.PollKind.POLLS_ONLY -> R.string.search_operator_poll_only
|
||||||
|
HasPollOperator.PollKind.NO_POLLS -> R.string.search_operator_poll_no_polls
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IsReplyOperatorViewData(override val operator: IsReplyOperator) : SearchOperatorViewData<IsReplyOperator> {
|
||||||
|
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||||
|
null -> context.getString(R.string.search_operator_replies_all)
|
||||||
|
IsReplyOperator.ReplyKind.REPLIES_ONLY -> context.getString(R.string.search_operator_replies_replies_only)
|
||||||
|
IsReplyOperator.ReplyKind.NO_REPLIES -> context.getString(R.string.search_operator_replies_no_replies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IsSensitiveOperatorViewData(override val operator: IsSensitiveOperator) : SearchOperatorViewData<IsSensitiveOperator> {
|
||||||
|
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||||
|
null -> context.getString(R.string.search_operator_sensitive_all)
|
||||||
|
IsSensitiveOperator.SensitiveKind.SENSITIVE_ONLY -> context.getString(R.string.search_operator_sensitive_sensitive_only)
|
||||||
|
IsSensitiveOperator.SensitiveKind.NO_SENSITIVE -> context.getString(R.string.search_operator_sensitive_no_sensitive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WhereOperatorViewData(override val operator: WhereOperator) : SearchOperatorViewData<WhereOperator> {
|
||||||
|
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||||
|
null -> context.getString(R.string.search_operator_where_all)
|
||||||
|
WhereOperator.WhereLocation.LIBRARY -> context.getString(R.string.search_operator_where_library)
|
||||||
|
WhereOperator.WhereLocation.PUBLIC -> context.getString(R.string.search_operator_where_public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -21,14 +21,28 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.paging.Pager
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
|
import app.pachli.components.compose.ComposeAutoCompleteAdapter
|
||||||
|
import app.pachli.components.search.SearchOperator.DateOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.FromOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasEmbedOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasLinkOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasMediaOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.HasPollOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.IsReplyOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.IsSensitiveOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.LanguageOperator
|
||||||
|
import app.pachli.components.search.SearchOperator.WhereOperator
|
||||||
import app.pachli.components.search.adapter.SearchPagingSourceFactory
|
import app.pachli.components.search.adapter.SearchPagingSourceFactory
|
||||||
import app.pachli.core.accounts.AccountManager
|
import app.pachli.core.accounts.AccountManager
|
||||||
|
import app.pachli.core.data.repository.ServerRepository
|
||||||
import app.pachli.core.database.model.AccountEntity
|
import app.pachli.core.database.model.AccountEntity
|
||||||
import app.pachli.core.network.model.DeletedStatus
|
import app.pachli.core.network.model.DeletedStatus
|
||||||
import app.pachli.core.network.model.Poll
|
import app.pachli.core.network.model.Poll
|
||||||
import app.pachli.core.network.model.Status
|
import app.pachli.core.network.model.Status
|
||||||
import app.pachli.core.network.retrofit.MastodonApi
|
import app.pachli.core.network.retrofit.MastodonApi
|
||||||
import app.pachli.usecase.TimelineCases
|
import app.pachli.usecase.TimelineCases
|
||||||
|
import app.pachli.util.getInitialLanguages
|
||||||
|
import app.pachli.util.getLocaleList
|
||||||
import app.pachli.viewdata.StatusViewData
|
import app.pachli.viewdata.StatusViewData
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
@ -37,14 +51,21 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
private val timelineCases: TimelineCases,
|
private val timelineCases: TimelineCases,
|
||||||
private val accountManager: AccountManager,
|
private val accountManager: AccountManager,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
var currentQuery: String = ""
|
var currentQuery: String = ""
|
||||||
@ -57,6 +78,46 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
private val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
||||||
private val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
private val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
||||||
|
|
||||||
|
private val _operatorViewData = MutableStateFlow(
|
||||||
|
setOf(
|
||||||
|
SearchOperatorViewData.from(HasMediaOperator()),
|
||||||
|
SearchOperatorViewData.from(DateOperator()),
|
||||||
|
SearchOperatorViewData.from(HasEmbedOperator()),
|
||||||
|
SearchOperatorViewData.from(FromOperator()),
|
||||||
|
SearchOperatorViewData.from(LanguageOperator()),
|
||||||
|
SearchOperatorViewData.from(HasLinkOperator()),
|
||||||
|
SearchOperatorViewData.from(HasPollOperator()),
|
||||||
|
SearchOperatorViewData.from(IsReplyOperator()),
|
||||||
|
SearchOperatorViewData.from(IsSensitiveOperator()),
|
||||||
|
SearchOperatorViewData.from(WhereOperator()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete set of [SearchOperatorViewData].
|
||||||
|
*
|
||||||
|
* Items are never added or removed from this, only replaced with [replaceOperator].
|
||||||
|
* An item can be retrieved by class using [getOperator]
|
||||||
|
*
|
||||||
|
* @see [replaceOperator]
|
||||||
|
* @see [getOperator]
|
||||||
|
*/
|
||||||
|
val operatorViewData = _operatorViewData.asStateFlow()
|
||||||
|
|
||||||
|
val locales = accountManager.activeAccountFlow.map {
|
||||||
|
getLocaleList(getInitialLanguages(activeAccount = it))
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5000),
|
||||||
|
getLocaleList(getInitialLanguages()),
|
||||||
|
)
|
||||||
|
|
||||||
|
val server = serverRepository.flow.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5000),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
private val loadedStatuses: MutableList<StatusViewData> = mutableListOf()
|
private val loadedStatuses: MutableList<StatusViewData> = mutableListOf()
|
||||||
|
|
||||||
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
||||||
@ -81,26 +142,37 @@ class SearchViewModel @Inject constructor(
|
|||||||
val statusesFlow = Pager(
|
val statusesFlow = Pager(
|
||||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||||
pagingSourceFactory = statusesPagingSourceFactory,
|
pagingSourceFactory = statusesPagingSourceFactory,
|
||||||
).flow
|
).flow.cachedIn(viewModelScope)
|
||||||
.cachedIn(viewModelScope)
|
|
||||||
|
|
||||||
val accountsFlow = Pager(
|
val accountsFlow = Pager(
|
||||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||||
pagingSourceFactory = accountsPagingSourceFactory,
|
pagingSourceFactory = accountsPagingSourceFactory,
|
||||||
).flow
|
).flow.cachedIn(viewModelScope)
|
||||||
.cachedIn(viewModelScope)
|
|
||||||
|
|
||||||
val hashtagsFlow = Pager(
|
val hashtagsFlow = Pager(
|
||||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||||
pagingSourceFactory = hashtagsPagingSourceFactory,
|
pagingSourceFactory = hashtagsPagingSourceFactory,
|
||||||
).flow
|
).flow.cachedIn(viewModelScope)
|
||||||
.cachedIn(viewModelScope)
|
|
||||||
|
/** @return The operator of type T. */
|
||||||
|
inline fun <reified T : SearchOperator> getOperator() = operatorViewData.value.find { it.operator is T }?.operator as T?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the existing [SearchOperatorViewData] in [_operatorViewData]
|
||||||
|
* with [viewData].
|
||||||
|
*/
|
||||||
|
fun <T : SearchOperator> replaceOperator(viewData: SearchOperatorViewData<T>) = _operatorViewData.update { operators ->
|
||||||
|
operators.find { it.javaClass == viewData.javaClass }?.let { operators - it + viewData } ?: (operators + viewData)
|
||||||
|
}
|
||||||
|
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
|
val operatorQuery = _operatorViewData.value.mapNotNull { it.operator.query() }.joinToString(" ")
|
||||||
|
val finalQuery = if (operatorQuery.isNotBlank()) arrayOf(query, operatorQuery).joinToString(" ") else query
|
||||||
|
|
||||||
loadedStatuses.clear()
|
loadedStatuses.clear()
|
||||||
statusesPagingSourceFactory.newSearch(query)
|
statusesPagingSourceFactory.newSearch(finalQuery)
|
||||||
accountsPagingSourceFactory.newSearch(query)
|
accountsPagingSourceFactory.newSearch(finalQuery)
|
||||||
hashtagsPagingSourceFactory.newSearch(query)
|
hashtagsPagingSourceFactory.newSearch(finalQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeItem(statusViewData: StatusViewData) {
|
fun removeItem(statusViewData: StatusViewData) {
|
||||||
@ -194,6 +266,21 @@ class SearchViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Searches for autocomplete suggestions. */
|
||||||
|
suspend fun searchAccountAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||||
|
// "resolve" is false as, by definition, the server will only return statuses it
|
||||||
|
// knows about, therefore the accounts that posted those statuses will definitely
|
||||||
|
// be known by the server and there is no need to resolve them further.
|
||||||
|
return mastodonApi.search(query = token, resolve = false, type = SearchType.Account.apiParameter, limit = 10)
|
||||||
|
.fold(
|
||||||
|
{ it.accounts.map { ComposeAutoCompleteAdapter.AutocompleteResult.AccountResult(it) } },
|
||||||
|
{
|
||||||
|
Timber.e(it, "Autocomplete search for %s failed.", token)
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateStatusViewData(newStatusViewData: StatusViewData) {
|
private fun updateStatusViewData(newStatusViewData: StatusViewData) {
|
||||||
val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id }
|
val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id }
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
@ -4,6 +4,6 @@
|
|||||||
android:viewportWidth="24.0"
|
android:viewportWidth="24.0"
|
||||||
android:viewportHeight="24.0">
|
android:viewportHeight="24.0">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#fff"
|
android:fillColor="?attr/colorControlNormal"
|
||||||
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z"/>
|
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z"/>
|
||||||
</vector>
|
</vector>
|
||||||
|
22
app/src/main/res/drawable/ic_link_24.xml
Normal file
22
app/src/main/res/drawable/ic_link_24.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2024 Pachli Association
|
||||||
|
~
|
||||||
|
~ This file is a part of Pachli.
|
||||||
|
~
|
||||||
|
~ This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
~ License, or (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ Pachli 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 General
|
||||||
|
~ Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||||
|
~ see <http://www.gnu.org/licenses>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="?attr/colorControlNormal" android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
|
||||||
|
|
||||||
|
</vector>
|
@ -5,6 +5,6 @@
|
|||||||
android:viewportHeight="24.0"
|
android:viewportHeight="24.0"
|
||||||
android:viewportWidth="24.0">
|
android:viewportWidth="24.0">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#fff"
|
android:fillColor="?attr/colorControlNormal"
|
||||||
android:pathData="M7,8L7,5l-7,7 7,7v-3l-4,-4 4,-4zM13,9L13,5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
|
android:pathData="M7,8L7,5l-7,7 7,7v-3l-4,-4 4,-4zM13,9L13,5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
@ -21,6 +21,101 @@
|
|||||||
app:layout_scrollFlags="scroll|snap|enterAlways"
|
app:layout_scrollFlags="scroll|snap|enterAlways"
|
||||||
app:navigationIcon="?attr/homeAsUpIndicator" />
|
app:navigationIcon="?attr/homeAsUpIndicator" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.ChipGroup
|
||||||
|
android:id="@+id/chipsFilter"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="?listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||||
|
app:layout_scrollFlags="scroll|snap|enterAlways"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipFrom"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_from_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipDate"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_date_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipLanguage"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_language_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_media"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIcon="@drawable/ic_attach_file_24dp"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_attachment_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_is_reply"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIcon="@drawable/ic_reply_all_24dp"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_replies_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_is_sensitive"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIcon="@drawable/ic_eye_24dp"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_sensitive_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_poll"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIcon="@drawable/ic_attach_file_24dp"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_poll_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_embed"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_embed_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_link"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIcon="@drawable/ic_link_24"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_link_all" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipWhere"
|
||||||
|
style="@style/Widget.Material3.Chip.Suggestion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:chipIconEnabled="true"
|
||||||
|
android:text="@string/search_operator_where_all" />
|
||||||
|
</com.google.android.material.chip.ChipGroup>
|
||||||
|
|
||||||
<com.google.android.material.tabs.TabLayout
|
<com.google.android.material.tabs.TabLayout
|
||||||
android:id="@+id/tabs"
|
android:id="@+id/tabs"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -4,6 +4,15 @@
|
|||||||
android:layout_width="@dimen/timeline_width"
|
android:layout_width="@dimen/timeline_width"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/searchProgressBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:contentDescription="" />
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipeRefreshLayout"
|
android:id="@+id/swipeRefreshLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@ -19,14 +28,6 @@
|
|||||||
tools:listitem="@layout/item_account" />
|
tools:listitem="@layout/item_account" />
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/searchProgressBar"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:indeterminate="true"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/searchNoResultsText"
|
android:id="@+id/searchNoResultsText"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
196
app/src/main/res/layout/search_operator_attachment_dialog.xml
Normal file
196
app/src/main/res/layout/search_operator_attachment_dialog.xml
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright 2024 Pachli Association
|
||||||
|
~
|
||||||
|
~ This file is a part of Pachli.
|
||||||
|
~
|
||||||
|
~ This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
~ License, or (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ Pachli 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 General
|
||||||
|
~ Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||||
|
~ see <http://www.gnu.org/licenses>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="?attr/listPreferredItemPaddingStart"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingEnd="?attr/listPreferredItemPaddingEnd">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.ChipGroup
|
||||||
|
android:id="@+id/chipgroup_media"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:selectionRequired="false"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_no_media"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_none" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_media"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_at_least_one" />
|
||||||
|
</com.google.android.material.chip.ChipGroup>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:id="@+id/mediaDivider"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/chipgroup_media" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.ChipGroup
|
||||||
|
android:id="@+id/chipgroup_images"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/mediaDivider"
|
||||||
|
app:selectionRequired="false"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_no_image"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_none" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_image"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_at_least_one" />
|
||||||
|
|
||||||
|
</com.google.android.material.chip.ChipGroup>
|
||||||
|
|
||||||
|
<com.google.android.material.chip.ChipGroup
|
||||||
|
android:id="@+id/chipgroup_video"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/chipgroup_media"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/chipgroup_images"
|
||||||
|
app:selectionRequired="false"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_no_video"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_none" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_video"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_at_least_one" />
|
||||||
|
</com.google.android.material.chip.ChipGroup>
|
||||||
|
|
||||||
|
<com.google.android.material.chip.ChipGroup
|
||||||
|
android:id="@+id/chipgroup_audio"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/chipgroup_video"
|
||||||
|
app:selectionRequired="false"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_no_audio"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_none" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_has_audio"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_attachment_at_least_one" />
|
||||||
|
</com.google.android.material.chip.ChipGroup>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/barrier4"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:barrierDirection="end"
|
||||||
|
app:constraint_referenced_ids="titleMedia,titleImage,titleVideo,titleAudio" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/titleMedia"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||||
|
android:text="@string/search_operator_attachment_dialog_any_label"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/chipgroup_media"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/chipgroup_media"
|
||||||
|
app:layout_constraintHorizontal_bias="1.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/chipgroup_media" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/titleImage"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||||
|
android:text="@string/search_operator_attachment_dialog_image_label"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constrainedHeight="false"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/chipgroup_video"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/chipgroup_images"
|
||||||
|
app:layout_constraintHorizontal_bias="1.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/mediaDivider" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/titleVideo"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||||
|
android:text="@string/search_operator_attachment_dialog_video_label"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/chipgroup_audio"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/chipgroup_video"
|
||||||
|
app:layout_constraintHorizontal_bias="1.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/chipgroup_video" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/titleAudio"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||||
|
android:text="@string/search_operator_attachment_dialog_audio_label"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/chipgroup_audio"
|
||||||
|
app:layout_constraintHorizontal_bias="1.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/chipgroup_audio" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
117
app/src/main/res/layout/search_operator_from_dialog.xml
Normal file
117
app/src/main/res/layout/search_operator_from_dialog.xml
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright 2024 Pachli Association
|
||||||
|
~
|
||||||
|
~ This file is a part of Pachli.
|
||||||
|
~
|
||||||
|
~ This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
~ License, or (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ Pachli 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 General
|
||||||
|
~ Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||||
|
~ see <http://www.gnu.org/licenses>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingStart="?attr/listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?attr/listPreferredItemPaddingEnd">
|
||||||
|
|
||||||
|
<!-- textNoSuggestions is to disable spell check, it will auto-complete -->
|
||||||
|
<RadioGroup
|
||||||
|
android:id="@+id/radioGroup"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
tools:layout_editor_absoluteY="4dp">
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioAll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:text="@string/search_operator_from_dialog_all"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioMe"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:text="@string/search_operator_from_dialog_me"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioIgnoreMe"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
tools:ignore="RtlSymmetry"
|
||||||
|
android:text="@string/search_operator_from_dialog_ignore_me" />
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:id="@+id/materialDivider"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:layout_marginBottom="6dp" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioOtherAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:text="@string/search_operator_from_dialog_other_account"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioIgnoreOtherAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:text="@string/search_operator_from_dialog_ignore_other_account"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
style="@style/AppTextInput"
|
||||||
|
android:id="@+id/accountEditTextLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="48dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||||
|
android:id="@+id/account"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="48dp"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:hint="@string/search_operator_from_dialog_account_hint"/>
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
</RadioGroup>
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright 2024 Pachli Association
|
||||||
|
~
|
||||||
|
~ This file is a part of Pachli.
|
||||||
|
~
|
||||||
|
~ This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
~ License, or (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ Pachli 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 General
|
||||||
|
~ Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||||
|
~ see <http://www.gnu.org/licenses>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Displays the options for SearchViewModel.WhereOperator.
|
||||||
|
|
||||||
|
This **cannot** be a listview because alert dialogs assume the height of any
|
||||||
|
interior listview can be computed by taking the number of items and multiplying
|
||||||
|
by the height of the first item (i.e., items are an identical height).
|
||||||
|
|
||||||
|
This is not the case for this layout, as the descriptions for the three options
|
||||||
|
may span multiple lines, and the different items may be different heights.
|
||||||
|
|
||||||
|
This causes AlertDialog to make the list height too small, and enforces scrolling.
|
||||||
|
-->
|
||||||
|
<RadioGroup xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/where_all_dialog_group"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="fill_parent"
|
||||||
|
android:minHeight="?attr/listPreferredItemHeight"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
android:paddingEnd="?attr/dialogPreferredPadding"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/where_all_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_where_dialog_all"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/where_all_description"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_where_dialog_all_hint"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
android:paddingStart="52dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/where_library_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_where_dialog_library"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/where_library_description"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_where_dialog_library_hint"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
android:paddingStart="52dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/where_public_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_where_dialog_public"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/where_public_description"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/search_operator_where_dialog_public_hint"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||||
|
android:textColor="?attr/textColorAlertDialogListItem"
|
||||||
|
android:paddingStart="52dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
tools:ignore="RtlSymmetry" />
|
||||||
|
|
||||||
|
</RadioGroup>
|
@ -716,4 +716,109 @@
|
|||||||
<string name="error_load_filter_failed_fmt">Loading filter failed: %1$s</string>
|
<string name="error_load_filter_failed_fmt">Loading filter failed: %1$s</string>
|
||||||
<string name="error_save_filter_failed_fmt">Saving filter failed: %1$s</string>
|
<string name="error_save_filter_failed_fmt">Saving filter failed: %1$s</string>
|
||||||
<string name="error_delete_filter_failed_fmt">Deleting filter failed: %1$s</string>
|
<string name="error_delete_filter_failed_fmt">Deleting filter failed: %1$s</string>
|
||||||
|
|
||||||
|
<string name="search_operator_attachment_dialog_title">Limit to posts with attachments?</string>
|
||||||
|
<string name="search_operator_attachment_dialog_any_label">Any type</string>
|
||||||
|
<string name="search_operator_attachment_dialog_image_label">Images</string>
|
||||||
|
<string name="search_operator_attachment_dialog_video_label">Video</string>
|
||||||
|
<string name="search_operator_attachment_dialog_audio_label">Sound</string>
|
||||||
|
|
||||||
|
<string name="search_operator_attachment_all">Attachments ▾</string>
|
||||||
|
|
||||||
|
<string name="search_operator_attachment_no_media_label">No attachments</string>
|
||||||
|
<string name="search_operator_attachment_kind_image_label">images</string>
|
||||||
|
<string name="search_operator_attachment_kind_video_label">video</string>
|
||||||
|
<string name="search_operator_attachment_kind_audio_label">sound</string>
|
||||||
|
|
||||||
|
<string name="search_operator_attachment_has_media_label">At least one attachment</string>
|
||||||
|
<string name="search_operator_attachment_has_media_except_1_label_fmt">Any except %1$s</string>
|
||||||
|
<string name="search_operator_attachment_has_media_except_2_label_fmt">Any except %1$s, %2$s</string>
|
||||||
|
<string name="search_operator_attachment_has_media_except_3_label_fmt">Any except %1$s, %2$s, %3$s</string>
|
||||||
|
|
||||||
|
<string name="search_operator_attachment_specific_media_include_2_exclude_1_fmt">With %1$s, %2$s, no %3$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_include_1_exclude_2_fmt">With %1$s, no %2$s, %3$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_include_1_exclude_1_fmt">With %1$s, no %2$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_include_1_fmt">With %1$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_include_2_fmt">With %1$s, %2$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_include_3_fmt">With %1$s, %2$s, %3$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_exclude_1_fmt">No %1$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_exclude_2_fmt">No %1$s, %2$s</string>
|
||||||
|
<string name="search_operator_attachment_specific_media_exclude_3_fmt">No %1$s, %2$s, %3$s</string>
|
||||||
|
|
||||||
|
<string name="search_operator_attachment_none">None</string>
|
||||||
|
<string name="search_operator_attachment_at_least_one">At least one</string>
|
||||||
|
|
||||||
|
<string name="search_operator_from_all">From ▾</string>
|
||||||
|
<string name="search_operator_from_me">Only my posts</string>
|
||||||
|
<string name="search_operator_from_ignore_me">Not my posts</string>
|
||||||
|
<string name="search_operator_from_account_fmt">Only %1$s</string>
|
||||||
|
<string name="search_operator_from_ignore_account_fmt">Not %1$s</string>
|
||||||
|
<string name="search_operator_from_dialog_title">Limit to specific accounts?</string>
|
||||||
|
<string name="search_operator_from_dialog_all">All accounts</string>
|
||||||
|
<string name="search_operator_from_dialog_me">Only my posts</string>
|
||||||
|
<string name="search_operator_from_dialog_ignore_me">All posts except mine</string>
|
||||||
|
<string name="search_operator_from_dialog_other_account">Only posts from this account</string>
|
||||||
|
<string name="search_operator_from_dialog_ignore_other_account">All posts except this account</string>
|
||||||
|
<string name="search_operator_from_dialog_account_hint">\@account</string>
|
||||||
|
|
||||||
|
<string name="search_operator_poll_dialog_title">Show posts with polls?</string>
|
||||||
|
<string name="search_operator_poll_all">Polls ▾</string>
|
||||||
|
<string name="search_operator_poll_only">Only show polls</string>
|
||||||
|
<string name="search_operator_poll_no_polls">Without polls</string>
|
||||||
|
<string name="search_operator_poll_dialog_all">With or without polls</string>
|
||||||
|
<string name="search_operator_poll_dialog_only">Only posts with polls</string>
|
||||||
|
<string name="search_operator_poll_dialog_no_polls">Only posts without polls</string>
|
||||||
|
|
||||||
|
<string name="search_operator_embed_all">Preview cards ▾</string>
|
||||||
|
<string name="search_operator_embed_only">Only preview cards</string>
|
||||||
|
<string name="search_operator_embed_no_embeds">Without preview cards</string>
|
||||||
|
<string name="search_operator_embed_dialog_title">Show posts with preview cards?</string>
|
||||||
|
<string name="search_operator_embed_dialog_all">With or without preview cards</string>
|
||||||
|
<string name="search_operator_embed_dialog_only">Only posts with preview cards</string>
|
||||||
|
<string name="search_operator_embed_dialog_no_embeds">Only posts without preview cards</string>
|
||||||
|
|
||||||
|
<string name="search_operator_link_all">Links ▾</string>
|
||||||
|
<string name="search_operator_link_only">Only links</string>
|
||||||
|
<string name="search_operator_no_link">Without links</string>
|
||||||
|
<string name="search_operator_link_dialog_title">Show posts with links?</string>
|
||||||
|
<string name="search_operator_link_dialog_all">With or without links</string>
|
||||||
|
<string name="search_operator_link_dialog_only">Only posts with links</string>
|
||||||
|
<string name="search_operator_link_dialog_no_link">Only posts without links</string>
|
||||||
|
|
||||||
|
<string name="search_operator_date_all">Dates ▾</string>
|
||||||
|
<string name="search_operator_date_checked">%1$s - %2$s</string>
|
||||||
|
<string name="search_operator_date_checked_same_day">On %1$s</string>
|
||||||
|
<string name="search_operator_date_dialog_title">Limit to posts between…</string>
|
||||||
|
|
||||||
|
<string name="search_operator_language_dialog_title">Limit to posts written in…</string>
|
||||||
|
<string name="search_operator_language_all">All languages ▾</string>
|
||||||
|
<string name="search_operator_language_checked_fmt">%1$s</string>
|
||||||
|
<string name="search_operator_language_dialog_all">All languages</string>
|
||||||
|
|
||||||
|
<string name="search_operator_replies_all">Include replies ▾</string>
|
||||||
|
<string name="search_operator_replies_replies_only">Only show replies</string>
|
||||||
|
<string name="search_operator_replies_no_replies">Exclude replies</string>
|
||||||
|
<string name="search_operator_replies_dialog_title">Include replies?</string>
|
||||||
|
<string name="search_operator_replies_dialog_all">All posts</string>
|
||||||
|
<string name="search_operator_replies_dialog_replies_only">Only show replies</string>
|
||||||
|
<string name="search_operator_replies_dialog_no_replies">Without replies</string>
|
||||||
|
|
||||||
|
<string name="search_operator_sensitive_all">Include sensitive content ▾</string>
|
||||||
|
<string name="search_operator_sensitive_sensitive_only">Sensitive content only</string>
|
||||||
|
<string name="search_operator_sensitive_no_sensitive">Exclude sensitive content</string>
|
||||||
|
<string name="search_operator_sensitive_dialog_title">Posts with sensitive content</string>
|
||||||
|
<string name="search_operator_sensitive_dialog_all">All posts</string>
|
||||||
|
<string name="search_operator_sensitive_dialog_sensitive_only">Sensitive content only</string>
|
||||||
|
<string name="search_operator_sensitive_dialog_no_sensitive">Exclude sensitive content</string>
|
||||||
|
|
||||||
|
<string name="search_operator_where_all">All posts ▾</string>
|
||||||
|
<string name="search_operator_where_library">Your library</string>
|
||||||
|
<string name="search_operator_where_public">Public posts</string>
|
||||||
|
<string name="search_operator_where_dialog_title">Limit to…</string>
|
||||||
|
<string name="search_operator_where_dialog_all">All posts</string>
|
||||||
|
<string name="search_operator_where_dialog_all_hint">Posts in your library and public posts</string>
|
||||||
|
<string name="search_operator_where_dialog_library">Your library</string>
|
||||||
|
<string name="search_operator_where_dialog_library_hint">Your own posts, boosts, favorites, bookmarks, and posts that @mention you</string>
|
||||||
|
<string name="search_operator_where_dialog_public">Public posts</string>
|
||||||
|
<string name="search_operator_where_dialog_public_hint">Public, searchable posts known by server</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
@ -36,6 +36,20 @@ import app.pachli.core.network.ServerKind.SHARKEY
|
|||||||
import app.pachli.core.network.ServerKind.UNKNOWN
|
import app.pachli.core.network.ServerKind.UNKNOWN
|
||||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||||
|
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.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE
|
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE
|
||||||
import app.pachli.core.network.model.InstanceV1
|
import app.pachli.core.network.model.InstanceV1
|
||||||
import app.pachli.core.network.model.InstanceV2
|
import app.pachli.core.network.model.InstanceV2
|
||||||
@ -89,6 +103,30 @@ enum class ServerOperation(id: String, versions: List<Version>) {
|
|||||||
Version(major = 1, minor = 1),
|
Version(major = 1, minor = 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/** Search for posts from a particular account */
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_FROM(
|
||||||
|
"org.joinmastodon.search.query:from",
|
||||||
|
listOf(
|
||||||
|
// Initial introduction in Mastodon 3.5.0
|
||||||
|
Version(major = 1),
|
||||||
|
// Support for `from:me` in Mastodon 4.2.0
|
||||||
|
Version(major = 1, minor = 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE("org.joinmastodon.search.query:language", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA("org.joinmastodon.search.query:has:media", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE("org.joinmastodon.search.query:has:image", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO("org.joinmastodon.search.query:has:video", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO("org.joinmastodon.search.query:has:audio", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL("org.joinmastodon.search.query:has:poll", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK("org.joinmastodon.search.query:has:link", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED("org.joinmastodon.search.query:has:embed", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY("org.joinmastodon.search.query:is:reply", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE("org.joinmastodon.search.query:is:sensitive", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY("org.joinmastodon.search.query:in:library", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC("org.joinmastodon.search.query:in:public", listOf(Version(major = 1))),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE("org.joinmastodon.search.query:in:public", listOf(Version(major = 1))),
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Server(
|
data class Server(
|
||||||
@ -271,6 +309,33 @@ data class Server(
|
|||||||
when {
|
when {
|
||||||
v >= "4.0.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
v >= "4.0.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search operators
|
||||||
|
when {
|
||||||
|
v >= "4.3.0".toVersion() -> {
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC] = "1.0.0".toVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
v >= "4.2.0".toVersion() -> {
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.1.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE] = "1.0.0".toVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
v >= "3.5.0".toVersion() -> {
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GOTOSOCIAL -> {
|
GOTOSOCIAL -> {
|
||||||
@ -284,6 +349,14 @@ data class Server(
|
|||||||
// Implemented in https://github.com/superseriousbusiness/gotosocial/pull/2594
|
// Implemented in https://github.com/superseriousbusiness/gotosocial/pull/2594
|
||||||
v >= "0.15.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_CLIENT] = "1.1.0".toVersion()
|
v >= "0.15.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_CLIENT] = "1.1.0".toVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
when {
|
||||||
|
// from: in https://github.com/superseriousbusiness/gotosocial/pull/2943
|
||||||
|
v >= "0.16.0".toVersion() -> {
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FireFish can't filter (conversation in the Firefish dev. chat )
|
// FireFish can't filter (conversation in the Firefish dev. chat )
|
||||||
@ -292,9 +365,25 @@ data class Server(
|
|||||||
// Sharkey can't filter, https://activitypub.software/TransFem-org/Sharkey/-/issues/492
|
// Sharkey can't filter, https://activitypub.software/TransFem-org/Sharkey/-/issues/492
|
||||||
SHARKEY -> { }
|
SHARKEY -> { }
|
||||||
|
|
||||||
|
FRIENDICA -> {
|
||||||
|
// Assume filter support (may be wrong)
|
||||||
|
c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
||||||
|
|
||||||
|
// Search
|
||||||
|
when {
|
||||||
|
// Friendica has a number of search operators that are not in Mastodon.
|
||||||
|
// See https://github.com/friendica/friendica/blob/develop/doc/Channels.md
|
||||||
|
// for details.
|
||||||
|
v >= "2024.3.0".toVersion() -> {
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion()
|
||||||
|
c[ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE] = "1.0.0".toVersion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Everything else. Assume server side filtering and no translation. This may be an
|
// Everything else. Assume server side filtering and no translation. This may be an
|
||||||
// incorrect assumption.
|
// incorrect assumption.
|
||||||
AKKOMA, FEDIBIRD, FRIENDICA, HOMETOWN, ICESHRIMP, PIXELFED, PLEROMA, UNKNOWN -> {
|
AKKOMA, FEDIBIRD, HOMETOWN, ICESHRIMP, PIXELFED, PLEROMA, UNKNOWN -> {
|
||||||
c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,19 @@ import app.pachli.core.network.ServerKind.PLEROMA
|
|||||||
import app.pachli.core.network.ServerKind.UNKNOWN
|
import app.pachli.core.network.ServerKind.UNKNOWN
|
||||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||||
|
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_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.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE
|
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE
|
||||||
import app.pachli.core.network.model.Account
|
import app.pachli.core.network.model.Account
|
||||||
import app.pachli.core.network.model.Configuration
|
import app.pachli.core.network.model.Configuration
|
||||||
@ -133,6 +146,7 @@ class ServerTest(
|
|||||||
capabilities = mapOf(
|
capabilities = mapOf(
|
||||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -154,6 +168,7 @@ class ServerTest(
|
|||||||
capabilities = mapOf(
|
capabilities = mapOf(
|
||||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(),
|
||||||
ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.0.0".toVersion(),
|
ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.0.0".toVersion(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -161,7 +176,7 @@ class ServerTest(
|
|||||||
),
|
),
|
||||||
arrayOf(
|
arrayOf(
|
||||||
Triple(
|
Triple(
|
||||||
"Mastodon 4.2.0 has has translate 1.1.0",
|
"Mastodon 4.2.0 has translate 1.1.0",
|
||||||
NodeInfo.Software("mastodon", "4.2.0"),
|
NodeInfo.Software("mastodon", "4.2.0"),
|
||||||
defaultInstance.copy(
|
defaultInstance.copy(
|
||||||
configuration = defaultInstance.configuration.copy(
|
configuration = defaultInstance.configuration.copy(
|
||||||
@ -176,6 +191,19 @@ class ServerTest(
|
|||||||
capabilities = mapOf(
|
capabilities = mapOf(
|
||||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.1.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE to "1.0.0".toVersion(),
|
||||||
ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.1.0".toVersion(),
|
ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.1.0".toVersion(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -224,6 +252,7 @@ class ServerTest(
|
|||||||
capabilities = mapOf(
|
capabilities = mapOf(
|
||||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||||
|
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -18,9 +18,11 @@
|
|||||||
package app.pachli.core.ui.extensions
|
package app.pachli.core.ui.extensions
|
||||||
|
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
|
import android.content.DialogInterface.BUTTON_NEGATIVE
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import com.google.android.material.datepicker.MaterialDatePicker
|
||||||
|
import com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,7 +34,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||||||
* @param negativeText Optional text to show on the negative button
|
* @param negativeText Optional text to show on the negative button
|
||||||
* @param neutralText Optional text to show on the neutral button
|
* @param neutralText Optional text to show on the neutral button
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
suspend fun AlertDialog.await(
|
suspend fun AlertDialog.await(
|
||||||
positiveText: String,
|
positiveText: String,
|
||||||
negativeText: String? = null,
|
negativeText: String? = null,
|
||||||
@ -63,3 +64,84 @@ suspend fun AlertDialog.await(
|
|||||||
negativeTextResource?.let { context.getString(it) },
|
negativeTextResource?.let { context.getString(it) },
|
||||||
neutralTextResource?.let { context.getString(it) },
|
neutralTextResource?.let { context.getString(it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from [AlertDialog.Builder.awaitSingleChoiceItem].
|
||||||
|
*
|
||||||
|
* @param button ID of the button that was pressed, [AlertDialog.BUTTON_POSITIVE],
|
||||||
|
* [AlertDialog.BUTTON_NEGATIVE], or [AlertDialog.BUTTON_NEUTRAL].
|
||||||
|
* @param index Index of the selected item when the button was pressed.
|
||||||
|
*/
|
||||||
|
data class SingleChoiceItemResult(val button: Int, val index: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an [AlertDialog] displaying [items] with the item at
|
||||||
|
* [initialIndex] selected.
|
||||||
|
*
|
||||||
|
* @param items List of items to display.
|
||||||
|
* @param initialIndex Index of the item that should be selected when
|
||||||
|
* the dialog is shown. Use `-1` to leave all items unselected.
|
||||||
|
* @param positiveTextResource String resource to use as positive button
|
||||||
|
* text
|
||||||
|
* @param negativeTextResource Optional string resource to use as negative
|
||||||
|
* button text. If null the button is not shown.
|
||||||
|
* @param neutralTextResource Optional string resource to use as neutral
|
||||||
|
* button tet. If null the button is not shown.
|
||||||
|
* @return [SingleChoiceItemResult] with the button that was pressed and
|
||||||
|
* the index of the selected item in [items] when the button was pressed.
|
||||||
|
*/
|
||||||
|
suspend inline fun <reified T : CharSequence> AlertDialog.Builder.awaitSingleChoiceItem(
|
||||||
|
items: List<T>,
|
||||||
|
initialIndex: Int,
|
||||||
|
@StringRes positiveTextResource: Int,
|
||||||
|
@StringRes negativeTextResource: Int? = null,
|
||||||
|
@StringRes neutralTextResource: Int? = null,
|
||||||
|
) = suspendCancellableCoroutine { cont ->
|
||||||
|
var selectedIndex = initialIndex
|
||||||
|
|
||||||
|
val itemListener = DialogInterface.OnClickListener { _, which ->
|
||||||
|
selectedIndex = which
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonListener = DialogInterface.OnClickListener { _, which ->
|
||||||
|
cont.resume(SingleChoiceItemResult(which, selectedIndex)) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
setSingleChoiceItems(items.toTypedArray(), selectedIndex, itemListener)
|
||||||
|
setPositiveButton(positiveTextResource, buttonListener)
|
||||||
|
negativeTextResource?.let { setNegativeButton(it, buttonListener) }
|
||||||
|
neutralTextResource?.let { setNeutralButton(it, buttonListener) }
|
||||||
|
setOnCancelListener { cont.resume(SingleChoiceItemResult(BUTTON_NEGATIVE, selectedIndex)) {} }
|
||||||
|
setOnDismissListener { if (!cont.isCompleted) cont.resume(SingleChoiceItemResult(BUTTON_NEGATIVE, selectedIndex)) {} }
|
||||||
|
val dialog = create()
|
||||||
|
|
||||||
|
cont.invokeOnCancellation { dialog.dismiss() }
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a [MaterialDatePicker] and returns the result after the user makes
|
||||||
|
* their selection.
|
||||||
|
*
|
||||||
|
* @param fragmentManager The FragmentManager this fragment will be added to
|
||||||
|
* @param tag Optional tag name for the fragment, to later retrieve the fragment with
|
||||||
|
* [FragmentManager.findFragmentByTag(String)][androidx.fragment.app.FragmentManager.findFragmentById]
|
||||||
|
* @return Object of type [S] if the dialog's positive button is clicked,
|
||||||
|
* otherwise null.
|
||||||
|
*/
|
||||||
|
suspend fun <S> MaterialDatePicker<S>.await(
|
||||||
|
fragmentManager: androidx.fragment.app.FragmentManager,
|
||||||
|
tag: String?,
|
||||||
|
) = suspendCancellableCoroutine { cont ->
|
||||||
|
val listener = MaterialPickerOnPositiveButtonClickListener<S> { selection ->
|
||||||
|
cont.resume(selection) { dismiss() }
|
||||||
|
}
|
||||||
|
|
||||||
|
addOnPositiveButtonClickListener(listener)
|
||||||
|
addOnNegativeButtonClickListener { cont.resume(null) { dismiss() } }
|
||||||
|
addOnCancelListener { cont.resume(null) { dismiss() } }
|
||||||
|
addOnDismissListener { if (!cont.isCompleted) cont.resume(null) { dismiss() } }
|
||||||
|
cont.invokeOnCancellation { dismiss() }
|
||||||
|
|
||||||
|
show(fragmentManager, tag)
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user