From 6c4a388c7e6a9f90af7492a890703938387e9a90 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 3 Nov 2021 13:56:08 -0400 Subject: [PATCH] Multi-delete notifications --- .../main/java/io/heckel/ntfy/data/Database.kt | 4 +- .../java/io/heckel/ntfy/data/Repository.kt | 4 +- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 113 +++++++++++++++++- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 23 ++-- .../java/io/heckel/ntfy/ui/DetailViewModel.kt | 4 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 19 +-- .../java/io/heckel/ntfy/ui/MainAdapter.kt | 4 +- app/src/main/java/io/heckel/ntfy/ui/Util.kt | 15 +++ .../main/res/menu/detail_action_mode_menu.xml | 4 + .../main/res/menu/main_action_mode_menu.xml | 2 +- app/src/main/res/values/strings.xml | 7 ++ 11 files changed, 163 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/ui/Util.kt create mode 100644 app/src/main/res/menu/detail_action_mode_menu.xml diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 0d61ae6..ec36405 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -70,8 +70,8 @@ interface NotificationDao { @Insert fun add(notification: Notification) - @Delete - fun remove(notification: Notification) + @Query("DELETE FROM notification WHERE id = :notificationId") + fun remove(notificationId: String) @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId") fun removeAll(subscriptionId: Long) diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 1d390bb..e595e8c 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -46,8 +46,8 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif @Suppress("RedundantSuspendModifier") @WorkerThread - suspend fun removeNotification(notification: Notification) { - notificationDao.remove(notification) + suspend fun removeNotification(notificationId: String) { + notificationDao.remove(notificationId) } @Suppress("RedundantSuspendModifier") diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index ae73853..efe061a 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import android.text.Html import android.util.Log +import android.view.ActionMode import android.view.Menu import android.view.MenuItem import android.view.View @@ -12,6 +13,7 @@ import android.widget.TextView import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.Volley @@ -23,14 +25,21 @@ import io.heckel.ntfy.data.topicUrl import java.util.* -class DetailActivity : AppCompatActivity() { +class DetailActivity : AppCompatActivity(), ActionMode.Callback { private val viewModel by viewModels { DetailViewModelFactory((application as Application).repository) } + + // Which subscription are we looking at private var subscriptionId: Long = 0L // Set in onCreate() private var subscriptionBaseUrl: String = "" // Set in onCreate() private var subscriptionTopic: String = "" // Set in onCreate() + // Action mode stuff + private lateinit var mainList: RecyclerView + private lateinit var adapter: DetailAdapter + private var actionMode: ActionMode? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.detail_activity) @@ -59,8 +68,11 @@ class DetailActivity : AppCompatActivity() { // Update main list based on viewModel (& its datasource/livedata) val noEntriesText: View = findViewById(R.id.detail_no_notifications) - val adapter = DetailAdapter { notification -> onNotificationClick(notification) } - val mainList: RecyclerView = findViewById(R.id.detail_notification_list) + val onNotificationClick = { n: Notification -> onNotificationClick(n) } + val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) } + + adapter = DetailAdapter(onNotificationClick, onNotificationLongClick) + mainList = findViewById(R.id.detail_notification_list) mainList.adapter = adapter viewModel.list(subscriptionId).observe(this) { @@ -141,7 +153,100 @@ class DetailActivity : AppCompatActivity() { } private fun onNotificationClick(notification: Notification) { - // TODO Do something + if (actionMode != null) { + handleActionModeClick(notification) + } + } + + private fun onNotificationLongClick(notification: Notification) { + if (actionMode == null) { + beginActionMode(notification) + } + } + + private fun handleActionModeClick(notification: Notification) { + adapter.toggleSelection(notification.id) + if (adapter.selected.size == 0) { + finishActionMode() + } else { + actionMode!!.title = adapter.selected.size.toString() + redrawList() + } + } + + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + this.actionMode = mode + if (mode != null) { + mode.menuInflater.inflate(R.menu.detail_action_mode_menu, menu) + mode.title = "1" // One item selected + } + return true + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return false + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + return when (item?.itemId) { + R.id.detail_action_mode_delete -> { + onMultiDeleteClick() + true + } + else -> false + } + } + + private fun onMultiDeleteClick() { + Log.d(TAG, "Showing multi-delete dialog for selected items") + + val builder = AlertDialog.Builder(this) + builder + .setMessage(R.string.detail_action_mode_delete_dialog_message) + .setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ -> + adapter.selected.map { viewModel.remove(it) } + finishActionMode() + } + .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> + finishActionMode() + } + .create() + .show() + } + + override fun onDestroyActionMode(mode: ActionMode?) { + endActionModeAndRedraw() + } + + private fun beginActionMode(notification: Notification) { + actionMode = startActionMode(this) + adapter.selected.add(notification.id) + redrawList() + + // Fade status bar color + val fromColor = ContextCompat.getColor(this, R.color.primaryColor) + val toColor = ContextCompat.getColor(this, R.color.primaryDarkColor) + fadeStatusBarColor(window, fromColor, toColor) + } + + private fun finishActionMode() { + actionMode!!.finish() + endActionModeAndRedraw() + } + + private fun endActionModeAndRedraw() { + actionMode = null + adapter.selected.clear() + redrawList() + + // Fade status bar color + val fromColor = ContextCompat.getColor(this, R.color.primaryDarkColor) + val toColor = ContextCompat.getColor(this, R.color.primaryColor) + fadeStatusBarColor(window, fromColor, toColor) + } + + private fun redrawList() { + mainList.adapter = adapter // Oh, what a hack ... } companion object { diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index ec7fe5f..fa5ba80 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -1,6 +1,5 @@ package io.heckel.ntfy.ui -import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -10,19 +9,17 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import io.heckel.ntfy.R import io.heckel.ntfy.data.Notification -import io.heckel.ntfy.data.Subscription -import io.heckel.ntfy.data.topicShortUrl -import java.time.Instant import java.util.* -class DetailAdapter(private val onClick: (Notification) -> Unit) : +class DetailAdapter(private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : ListAdapter(TopicDiffCallback) { + val selected = mutableSetOf() // Notification IDs /* Creates and inflates view and return TopicViewHolder. */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.detail_fragment_item, parent, false) - return DetailViewHolder(view, onClick) + return DetailViewHolder(view, selected, onClick, onLongClick) } /* Gets current topic and uses it to bind view. */ @@ -30,8 +27,16 @@ class DetailAdapter(private val onClick: (Notification) -> Unit) : holder.bind(getItem(position)) } + fun toggleSelection(notificationId: String) { + if (selected.contains(notificationId)) { + selected.remove(notificationId) + } else { + selected.add(notificationId) + } + } + /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class DetailViewHolder(itemView: View, val onClick: (Notification) -> Unit) : + class DetailViewHolder(itemView: View, private val selected: Set, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) : RecyclerView.ViewHolder(itemView) { private var notification: Notification? = null private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text) @@ -42,6 +47,10 @@ class DetailAdapter(private val onClick: (Notification) -> Unit) : dateView.text = Date(notification.timestamp * 1000).toString() messageView.text = notification.message itemView.setOnClickListener { onClick(notification) } + itemView.setOnLongClickListener { onLongClick(notification); true } + if (selected.contains(notification.id)) { + itemView.setBackgroundResource(R.color.primarySelectedRowColor); + } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt index 332ca1c..c460583 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -19,8 +19,8 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { repository.addNotification(notification) } - fun remove(notification: Notification) = viewModelScope.launch(Dispatchers.IO) { - repository.removeNotification(notification) + fun remove(notificationId: String) = viewModelScope.launch(Dispatchers.IO) { + repository.removeNotification(notificationId) } fun removeAll(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 4c3c17d..095d2b1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -2,8 +2,6 @@ package io.heckel.ntfy.ui import android.animation.Animator import android.animation.AnimatorListenerAdapter -import android.animation.ArgbEvaluator -import android.animation.ValueAnimator import android.app.AlertDialog import android.content.Intent import android.net.Uri @@ -169,7 +167,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { return when (item?.itemId) { - R.id.main_action_mode_unsubscribe -> { + R.id.main_action_mode_delete -> { onMultiDeleteClick() true } @@ -218,7 +216,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { // Fade status bar color val fromColor = ContextCompat.getColor(this, R.color.primaryColor) val toColor = ContextCompat.getColor(this, R.color.primaryDarkColor) - fadeStatusBarColor(fromColor, toColor) + fadeStatusBarColor(window, fromColor, toColor) } private fun finishActionMode() { @@ -247,24 +245,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { // Fade status bar color val fromColor = ContextCompat.getColor(this, R.color.primaryDarkColor) val toColor = ContextCompat.getColor(this, R.color.primaryColor) - fadeStatusBarColor(fromColor, toColor) - + fadeStatusBarColor(window, fromColor, toColor) } private fun redrawList() { mainList.adapter = adapter // Oh, what a hack ... } - private fun fadeStatusBarColor(fromColor: Int, toColor: Int) { - // Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785 - val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor) - statusBarColorAnimation.addUpdateListener { animator -> - val color = animator.animatedValue as Int - window.statusBarColor = color - } - statusBarColorAnimation.start() - } - companion object { const val TAG = "NtfyMainActivity" const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index 8f8f556..8068657 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -15,7 +15,7 @@ import io.heckel.ntfy.data.topicShortUrl class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) : ListAdapter(TopicDiffCallback) { - val selected = mutableSetOf() + val selected = mutableSetOf() // Subscription IDs /* Creates and inflates view and return TopicViewHolder. */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { @@ -39,7 +39,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon } /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class SubscriptionViewHolder(itemView: View, val selected: Set, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) : + class SubscriptionViewHolder(itemView: View, private val selected: Set, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) : RecyclerView.ViewHolder(itemView) { private var subscription: Subscription? = null private val context: Context = itemView.context diff --git a/app/src/main/java/io/heckel/ntfy/ui/Util.kt b/app/src/main/java/io/heckel/ntfy/ui/Util.kt new file mode 100644 index 0000000..d37213e --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/Util.kt @@ -0,0 +1,15 @@ +package io.heckel.ntfy.ui + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.view.Window + +// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785 +fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) { + val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor) + statusBarColorAnimation.addUpdateListener { animator -> + val color = animator.animatedValue as Int + window.statusBarColor = color + } + statusBarColorAnimation.start() +} diff --git a/app/src/main/res/menu/detail_action_mode_menu.xml b/app/src/main/res/menu/detail_action_mode_menu.xml new file mode 100644 index 0000000..8c478a3 --- /dev/null +++ b/app/src/main/res/menu/detail_action_mode_menu.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/menu/main_action_mode_menu.xml b/app/src/main/res/menu/main_action_mode_menu.xml index aedb37d..bf163a4 100644 --- a/app/src/main/res/menu/main_action_mode_menu.xml +++ b/app/src/main/res/menu/main_action_mode_menu.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 546ed30..250719b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ Visit ntfy.sh + Unsubscribe Do you really want to unsubscribe from selected topic(s) and permanently delete all the messages you received? Permanently delete Cancel @@ -49,4 +50,10 @@ Send test notification Unsubscribe + + + Delete + Do you really want to permanently delete the selected message(s)? + Permanently delete + Cancel