package com.keylesspalace.tusky.components.filters import android.content.Context import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.core.content.IntentCompat import androidx.core.view.size import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import com.google.android.material.switchmaterial.SwitchMaterial import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding import com.keylesspalace.tusky.databinding.DialogFilterBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.launch import java.util.Date import javax.inject.Inject class EditFilterActivity : BaseActivity() { @Inject lateinit var api: MastodonApi @Inject lateinit var eventHub: EventHub @Inject lateinit var viewModelFactory: ViewModelFactory private val binding by viewBinding(ActivityEditFilterBinding::inflate) private val viewModel: EditFilterViewModel by viewModels { viewModelFactory } private lateinit var filter: Filter private var originalFilter: Filter? = null private lateinit var contextSwitches: Map override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java) filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf()) binding.apply { contextSwitches = mapOf( filterContextHome to Filter.Kind.HOME, filterContextNotifications to Filter.Kind.NOTIFICATIONS, filterContextPublic to Filter.Kind.PUBLIC, filterContextThread to Filter.Kind.THREAD, filterContextAccount to Filter.Kind.ACCOUNT ) } setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { // Back button setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } setTitle( if (originalFilter == null) { R.string.filter_addition_title } else { R.string.filter_edit_title } ) binding.actionChip.setOnClickListener { showAddKeywordDialog() } binding.filterSaveButton.setOnClickListener { saveChanges() } binding.filterDeleteButton.setOnClickListener { lifecycleScope.launch { if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter() } } binding.filterDeleteButton.visible(originalFilter != null) for (switch in contextSwitches.keys) { switch.setOnCheckedChangeListener { _, isChecked -> val context = contextSwitches[switch]!! if (isChecked) { viewModel.addContext(context) } else { viewModel.removeContext(context) } validateSaveButton() } } binding.filterTitle.doAfterTextChanged { editable -> viewModel.setTitle(editable.toString()) validateSaveButton() } binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> viewModel.setAction( if (checked) { Filter.Action.WARN } else { Filter.Action.HIDE } ) } binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { viewModel.setDuration( if (originalFilter?.expiresAt == null) { position } else { position - 1 } ) } override fun onNothingSelected(parent: AdapterView<*>?) { viewModel.setDuration(0) } } validateSaveButton() if (originalFilter == null) { binding.filterActionWarn.isChecked = true } else { loadFilter() } observeModel() } private fun observeModel() { lifecycleScope.launch { viewModel.title.collect { title -> if (title != binding.filterTitle.text.toString()) { // We also get this callback when typing in the field, // which messes with the cursor focus binding.filterTitle.setText(title) } } } lifecycleScope.launch { viewModel.keywords.collect { keywords -> updateKeywords(keywords) } } lifecycleScope.launch { viewModel.contexts.collect { contexts -> for (entry in contextSwitches) { entry.key.isChecked = contexts.contains(entry.value) } } } lifecycleScope.launch { viewModel.action.collect { action -> when (action) { Filter.Action.HIDE -> binding.filterActionHide.isChecked = true else -> binding.filterActionWarn.isChecked = true } } } } // Populate the UI from the filter's members private fun loadFilter() { viewModel.load(filter) if (filter.expiresAt != null) { val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names) binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames) } } private fun updateKeywords(newKeywords: List) { newKeywords.forEachIndexed { index, filterKeyword -> val chip = binding.keywordChips.getChildAt(index).takeUnless { it.id == R.id.actionChip } as Chip? ?: Chip(this).apply { setCloseIconResource(R.drawable.ic_cancel_24dp) isCheckable = false binding.keywordChips.addView(this, binding.keywordChips.size - 1) } chip.text = if (filterKeyword.wholeWord) { binding.root.context.getString( R.string.filter_keyword_display_format, filterKeyword.keyword ) } else { filterKeyword.keyword } chip.isCloseIconVisible = true chip.setOnClickListener { showEditKeywordDialog(newKeywords[index]) } chip.setOnCloseIconClickListener { viewModel.deleteKeyword(newKeywords[index]) } } while (binding.keywordChips.size - 1 > newKeywords.size) { binding.keywordChips.removeViewAt(newKeywords.size) } filter = filter.copy(keywords = newKeywords) validateSaveButton() } private fun showAddKeywordDialog() { val binding = DialogFilterBinding.inflate(layoutInflater) binding.phraseWholeWord.isChecked = true AlertDialog.Builder(this) .setTitle(R.string.filter_keyword_addition_title) .setView(binding.root) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.addKeyword( FilterKeyword( "", binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked ) ) } .setNegativeButton(android.R.string.cancel, null) .show() } private fun showEditKeywordDialog(keyword: FilterKeyword) { val binding = DialogFilterBinding.inflate(layoutInflater) binding.phraseEditText.setText(keyword.keyword) binding.phraseWholeWord.isChecked = keyword.wholeWord AlertDialog.Builder(this) .setTitle(R.string.filter_edit_keyword_title) .setView(binding.root) .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> viewModel.modifyKeyword( keyword, keyword.copy( keyword = binding.phraseEditText.text.toString(), wholeWord = binding.phraseWholeWord.isChecked ) ) } .setNegativeButton(android.R.string.cancel, null) .show() } private fun validateSaveButton() { binding.filterSaveButton.isEnabled = viewModel.validate() } private fun saveChanges() { // TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? lifecycleScope.launch { if (viewModel.saveChanges(this@EditFilterActivity)) { finish() // Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter val affectedContexts = viewModel.contexts.value.map { it.kind }.union(originalFilter?.context ?: listOf()).distinct() eventHub.dispatch(FilterUpdatedEvent(affectedContexts)) } else { Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show() } } } private fun deleteFilter() { originalFilter?.let { filter -> lifecycleScope.launch { api.deleteFilter(filter.id).fold( { finish() }, { throwable -> if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { finish() }, { Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() } ) } else { Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() } } ) } } } companion object { const val FILTER_TO_EDIT = "FilterToEdit" // Mastodon *stores* the absolute date in the filter, // but create/edit take a number of seconds (relative to the time the operation is posted) fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? { return when (index) { -1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() } 0 -> null else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index) } } } }