From b65bc749ab4970752376d297b2295c61eef41e25 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 3 Nov 2021 12:48:13 -0400 Subject: [PATCH] Action mode delete for topics --- .../main/java/io/heckel/ntfy/data/Database.kt | 4 +- .../java/io/heckel/ntfy/ui/AddFragment.kt | 1 - .../java/io/heckel/ntfy/ui/DetailActivity.kt | 2 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 171 +++++++++++++++++- ...SubscriptionsAdapter.kt => MainAdapter.kt} | 22 ++- ...criptionsViewModel.kt => MainViewModel.kt} | 0 .../main/res/drawable/baseline_delete_20.xml | 10 + .../main/res/menu/detail_action_bar_menu.xml | 2 +- .../main/res/menu/main_action_mode_menu.xml | 4 + app/src/main/res/values/colors.xml | 4 +- app/src/main/res/values/strings.xml | 7 +- app/src/main/res/values/styles.xml | 1 + 12 files changed, 207 insertions(+), 21 deletions(-) rename app/src/main/java/io/heckel/ntfy/ui/{SubscriptionsAdapter.kt => MainAdapter.kt} (71%) rename app/src/main/java/io/heckel/ntfy/ui/{SubscriptionsViewModel.kt => MainViewModel.kt} (100%) create mode 100644 app/src/main/res/drawable/baseline_delete_20.xml create mode 100644 app/src/main/res/menu/main_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 9a98e5d..0d61ae6 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -10,8 +10,8 @@ data class Subscription( @PrimaryKey val id: Long, // Internal ID, only used in Repository and activities @ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "topic") val topic: String, - @ColumnInfo(name = "notifications") val notifications: Int, - @ColumnInfo(name = "lastActive") val lastActive: Long // Unix timestamp + @ColumnInfo(name = "notifications") val notifications: Int, + @ColumnInfo(name = "lastActive") val lastActive: Long, // Unix timestamp ) @Entity diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 4c6be7d..834440d 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -86,7 +86,6 @@ class AddFragment(private val viewModel: SubscriptionsViewModel, private val onS val topic = topicNameText.text.toString() val subscription = viewModel.get(baseUrl, topic) - println("sub $subscription") activity?.let { it.runOnUiThread { if (subscription != null) { 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 9e24152..ae73853 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -88,7 +88,7 @@ class DetailActivity : AppCompatActivity() { onTestClick() true } - R.id.detail_menu_delete -> { + R.id.detail_menu_unsubscribe -> { onDeleteClick() true } 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 50f200a..4c3c17d 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -1,14 +1,18 @@ 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 import android.os.Bundle import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.view.View +import android.view.* import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.google.firebase.messaging.FirebaseMessaging import io.heckel.ntfy.R @@ -18,30 +22,35 @@ import io.heckel.ntfy.data.topicShortUrl import java.util.* import kotlin.random.Random -class MainActivity : AppCompatActivity() { +class MainActivity : AppCompatActivity(), ActionMode.Callback { private val viewModel by viewModels { SubscriptionsViewModelFactory((application as Application).repository) } + private lateinit var mainList: RecyclerView + private lateinit var adapter: MainAdapter + private lateinit var fab: View + private var actionMode: ActionMode? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.main_activity) - // TODO implement multi-select delete - https://enoent.fr/posts/recyclerview-basics/ - // Action bar title = getString(R.string.main_action_bar_title) // Floating action button ("+") - val fab: View = findViewById(R.id.fab) + fab = findViewById(R.id.fab) fab.setOnClickListener { onSubscribeButtonClick() } // Update main list based on viewModel (& its datasource/livedata) val noEntries: View = findViewById(R.id.main_no_subscriptions) - val adapter = SubscriptionsAdapter { subscription -> onSubscriptionItemClick(subscription) } - val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list) + val onSubscriptionClick = { s: Subscription -> onSubscriptionItemClick(s) } + val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) } + + mainList = findViewById(R.id.main_subscriptions_list) + adapter = MainAdapter(onSubscriptionClick, onSubscriptionLongClick) mainList.adapter = adapter viewModel.list().observe(this) { @@ -85,7 +94,13 @@ class MainActivity : AppCompatActivity() { private fun onSubscribe(topic: String, baseUrl: String) { Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}") - val subscription = Subscription(id = Random.nextLong(), baseUrl = baseUrl, topic = topic, notifications = 0, lastActive = Date().time/1000) + val subscription = Subscription( + id = Random.nextLong(), + baseUrl = baseUrl, + topic = topic, + notifications = 0, + lastActive = Date().time/1000 + ) viewModel.add(subscription) FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl @@ -93,6 +108,20 @@ class MainActivity : AppCompatActivity() { } private fun onSubscriptionItemClick(subscription: Subscription) { + if (actionMode != null) { + handleActionModeClick(subscription) + } else { + startDetailView(subscription) + } + } + + private fun onSubscriptionItemLongClick(subscription: Subscription) { + if (actionMode == null) { + beginActionMode(subscription) + } + } + + private fun startDetailView(subscription: Subscription) { Log.d(TAG, "Entering detail view for subscription $subscription") val intent = Intent(this, DetailActivity::class.java) @@ -115,11 +144,133 @@ class MainActivity : AppCompatActivity() { } } + private fun handleActionModeClick(subscription: Subscription) { + adapter.toggleSelection(subscription.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.main_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.main_action_mode_unsubscribe -> { + onMultiDeleteClick() + true + } + else -> false + } + } + + private fun onMultiDeleteClick() { + Log.d(DetailActivity.TAG, "Showing multi-delete dialog for selected items") + + val builder = AlertDialog.Builder(this) + builder + .setMessage(R.string.main_action_mode_delete_dialog_message) + .setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ -> + adapter.selected.map { viewModel.remove(it) } + finishActionMode() + } + .setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ -> + finishActionMode() + } + .create() + .show() + } + + override fun onDestroyActionMode(mode: ActionMode?) { + endActionModeAndRedraw() + } + + private fun beginActionMode(subscription: Subscription) { + actionMode = startActionMode(this) + adapter.selected.add(subscription.id) + redrawList() + + // Fade out FAB + fab.alpha = 1f + fab + .animate() + .alpha(0f) + .setDuration(ANIMATION_DURATION) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + fab.visibility = View.GONE + } + }) + + // Fade status bar color + val fromColor = ContextCompat.getColor(this, R.color.primaryColor) + val toColor = ContextCompat.getColor(this, R.color.primaryDarkColor) + fadeStatusBarColor(fromColor, toColor) + } + + private fun finishActionMode() { + actionMode!!.finish() + endActionModeAndRedraw() + } + + private fun endActionModeAndRedraw() { + actionMode = null + adapter.selected.clear() + redrawList() + + // Fade in FAB + fab.alpha = 0f + fab.visibility = View.VISIBLE + fab + .animate() + .alpha(1f) + .setDuration(ANIMATION_DURATION) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + fab.visibility = View.VISIBLE // Required to replace the old listener + } + }) + + // Fade status bar color + val fromColor = ContextCompat.getColor(this, R.color.primaryDarkColor) + val toColor = ContextCompat.getColor(this, R.color.primaryColor) + fadeStatusBarColor(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" const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl" const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic" const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1 + const val ANIMATION_DURATION = 80L } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt similarity index 71% rename from app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt rename to app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index b85bba7..8f8f556 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -12,14 +12,16 @@ import io.heckel.ntfy.R import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.topicShortUrl -class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) : - ListAdapter(TopicDiffCallback) { + +class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) : + ListAdapter(TopicDiffCallback) { + val selected = mutableSetOf() /* Creates and inflates view and return TopicViewHolder. */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.main_fragment_item, parent, false) - return SubscriptionViewHolder(view, onClick) + return SubscriptionViewHolder(view, selected, onClick, onLongClick) } /* Gets current topic and uses it to bind view. */ @@ -28,8 +30,16 @@ class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) : holder.bind(subscription) } + fun toggleSelection(subscriptionId: Long) { + if (selected.contains(subscriptionId)) { + selected.remove(subscriptionId) + } else { + selected.add(subscriptionId) + } + } + /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class SubscriptionViewHolder(itemView: View, val onClick: (Subscription) -> Unit) : + class SubscriptionViewHolder(itemView: View, 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 @@ -46,6 +56,10 @@ class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) : nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic) statusView.text = statusMessage itemView.setOnClickListener { onClick(subscription) } + itemView.setOnLongClickListener { onLongClick(subscription); true } + if (selected.contains(subscription.id)) { + itemView.setBackgroundResource(R.color.primarySelectedRowColor); + } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt similarity index 100% rename from app/src/main/java/io/heckel/ntfy/ui/SubscriptionsViewModel.kt rename to app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt diff --git a/app/src/main/res/drawable/baseline_delete_20.xml b/app/src/main/res/drawable/baseline_delete_20.xml new file mode 100644 index 0000000..4f54b4b --- /dev/null +++ b/app/src/main/res/drawable/baseline_delete_20.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/menu/detail_action_bar_menu.xml b/app/src/main/res/menu/detail_action_bar_menu.xml index d0abbf5..7b6e5de 100644 --- a/app/src/main/res/menu/detail_action_bar_menu.xml +++ b/app/src/main/res/menu/detail_action_bar_menu.xml @@ -1,4 +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 new file mode 100644 index 0000000..aedb37d --- /dev/null +++ b/app/src/main/res/menu/main_action_mode_menu.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3d46211..a10c7ab 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,9 +2,11 @@ #338574 #338574 - #338574 + #2A6E60 #000000 #FFFFFF + + #EEEEEE diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 437928f..546ed30 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,11 @@ https://heckel.io/ntfy-android Visit ntfy.sh + + Do you really want to unsubscribe from selected topic(s) and permanently delete all the messages you received? + Permanently delete + Cancel + connecting … reconnecting … @@ -35,7 +40,7 @@ To send notifications to this topic, simply PUT or POST to the topic URL. $ curl -d \"Hi\" %1$s ]]> For more detailed instructions, check out the ntfy.sh website and documentation. - Do you really want to permanently delete this subscription and all its messages? + Do you really want to unsubscribe from this topic and delete all of the messages you received? Permanently delete Cancel This is a test notification from the Ntfy Android app. It was sent at %1$s. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d7a244f..ce6d2d0 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -4,5 +4,6 @@ @color/primaryDarkColor @color/primaryLightColor @color/primaryColor + @color/primaryDarkColor