Multi-delete notifications

This commit is contained in:
Philipp Heckel 2021-11-03 13:56:08 -04:00
parent b65bc749ab
commit 6c4a388c7e
11 changed files with 163 additions and 36 deletions

View file

@ -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)

View file

@ -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")

View file

@ -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<DetailViewModel> {
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 {

View file

@ -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<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<String>() // 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<String>, 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);
}
}
}

View file

@ -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) {

View file

@ -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"

View file

@ -15,7 +15,7 @@ import io.heckel.ntfy.data.topicShortUrl
class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<Long>()
val selected = mutableSetOf<Long>() // 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<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
class SubscriptionViewHolder(itemView: View, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
RecyclerView.ViewHolder(itemView) {
private var subscription: Subscription? = null
private val context: Context = itemView.context

View file

@ -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()
}

View file

@ -0,0 +1,4 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/detail_action_mode_delete" android:title="@string/detail_action_mode_menu_delete"
android:icon="@drawable/baseline_delete_20"/>
</menu>

View file

@ -1,4 +1,4 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/main_action_mode_unsubscribe" android:title="@string/detail_menu_unsubscribe"
<item android:id="@+id/main_action_mode_delete" android:title="@string/main_action_mode_menu_unsubscribe"
android:icon="@drawable/baseline_delete_20"/>
</menu>

View file

@ -14,6 +14,7 @@
<string name="main_menu_website_title">Visit ntfy.sh</string>
<!-- Main activity: Action mode -->
<string name="main_action_mode_menu_unsubscribe">Unsubscribe</string>
<string name="main_action_mode_delete_dialog_message">Do you really want to unsubscribe from selected topic(s) and permanently delete all the messages you received?</string>
<string name="main_action_mode_delete_dialog_permanently_delete">Permanently delete</string>
<string name="main_action_mode_delete_dialog_cancel">Cancel</string>
@ -49,4 +50,10 @@
<!-- Detail activity: Action bar -->
<string name="detail_menu_test">Send test notification</string>
<string name="detail_menu_unsubscribe">Unsubscribe</string>
<!-- Detail activity: Action mode -->
<string name="detail_action_mode_menu_delete">Delete</string>
<string name="detail_action_mode_delete_dialog_message">Do you really want to permanently delete the selected message(s)?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Permanently delete</string>
<string name="detail_action_mode_delete_dialog_cancel">Cancel</string>
</resources>