Action mode delete for topics

This commit is contained in:
Philipp Heckel 2021-11-03 12:48:13 -04:00
parent f3268deeda
commit b65bc749ab
12 changed files with 207 additions and 21 deletions

View file

@ -10,8 +10,8 @@ data class Subscription(
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities @PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "notifications") val notifications: Int, @ColumnInfo(name = "notifications") val notifications: Int,
@ColumnInfo(name = "lastActive") val lastActive: Long // Unix timestamp @ColumnInfo(name = "lastActive") val lastActive: Long, // Unix timestamp
) )
@Entity @Entity

View file

@ -86,7 +86,6 @@ class AddFragment(private val viewModel: SubscriptionsViewModel, private val onS
val topic = topicNameText.text.toString() val topic = topicNameText.text.toString()
val subscription = viewModel.get(baseUrl, topic) val subscription = viewModel.get(baseUrl, topic)
println("sub $subscription")
activity?.let { activity?.let {
it.runOnUiThread { it.runOnUiThread {
if (subscription != null) { if (subscription != null) {

View file

@ -88,7 +88,7 @@ class DetailActivity : AppCompatActivity() {
onTestClick() onTestClick()
true true
} }
R.id.detail_menu_delete -> { R.id.detail_menu_unsubscribe -> {
onDeleteClick() onDeleteClick()
true true
} }

View file

@ -1,14 +1,18 @@
package io.heckel.ntfy.ui 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.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Menu import android.view.*
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import io.heckel.ntfy.R import io.heckel.ntfy.R
@ -18,30 +22,35 @@ import io.heckel.ntfy.data.topicShortUrl
import java.util.* import java.util.*
import kotlin.random.Random import kotlin.random.Random
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity(), ActionMode.Callback {
private val viewModel by viewModels<SubscriptionsViewModel> { private val viewModel by viewModels<SubscriptionsViewModel> {
SubscriptionsViewModelFactory((application as Application).repository) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity) setContentView(R.layout.main_activity)
// TODO implement multi-select delete - https://enoent.fr/posts/recyclerview-basics/
// Action bar // Action bar
title = getString(R.string.main_action_bar_title) title = getString(R.string.main_action_bar_title)
// Floating action button ("+") // Floating action button ("+")
val fab: View = findViewById(R.id.fab) fab = findViewById(R.id.fab)
fab.setOnClickListener { fab.setOnClickListener {
onSubscribeButtonClick() onSubscribeButtonClick()
} }
// Update main list based on viewModel (& its datasource/livedata) // Update main list based on viewModel (& its datasource/livedata)
val noEntries: View = findViewById(R.id.main_no_subscriptions) val noEntries: View = findViewById(R.id.main_no_subscriptions)
val adapter = SubscriptionsAdapter { subscription -> onSubscriptionItemClick(subscription) } val onSubscriptionClick = { s: Subscription -> onSubscriptionItemClick(s) }
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list) val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) }
mainList = findViewById(R.id.main_subscriptions_list)
adapter = MainAdapter(onSubscriptionClick, onSubscriptionLongClick)
mainList.adapter = adapter mainList.adapter = adapter
viewModel.list().observe(this) { viewModel.list().observe(this) {
@ -85,7 +94,13 @@ class MainActivity : AppCompatActivity() {
private fun onSubscribe(topic: String, baseUrl: String) { private fun onSubscribe(topic: String, baseUrl: String) {
Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}") 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) viewModel.add(subscription)
FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl
@ -93,6 +108,20 @@ class MainActivity : AppCompatActivity() {
} }
private fun onSubscriptionItemClick(subscription: Subscription) { 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") Log.d(TAG, "Entering detail view for subscription $subscription")
val intent = Intent(this, DetailActivity::class.java) 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 { companion object {
const val TAG = "NtfyMainActivity" const val TAG = "NtfyMainActivity"
const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" const val EXTRA_SUBSCRIPTION_ID = "subscriptionId"
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl" const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic" const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1 const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1
const val ANIMATION_DURATION = 80L
} }
} }

View file

@ -12,14 +12,16 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) :
ListAdapter<Subscription, SubscriptionsAdapter.SubscriptionViewHolder>(TopicDiffCallback) { class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<Long>()
/* Creates and inflates view and return TopicViewHolder. */ /* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.main_fragment_item, parent, false) .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. */ /* Gets current topic and uses it to bind view. */
@ -28,8 +30,16 @@ class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) :
holder.bind(subscription) 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. */ /* 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<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
RecyclerView.ViewHolder(itemView) { RecyclerView.ViewHolder(itemView) {
private var subscription: Subscription? = null private var subscription: Subscription? = null
private val context: Context = itemView.context private val context: Context = itemView.context
@ -46,6 +56,10 @@ class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) :
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic) nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage statusView.text = statusMessage
itemView.setOnClickListener { onClick(subscription) } itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true }
if (selected.contains(subscription.id)) {
itemView.setBackgroundResource(R.color.primarySelectedRowColor);
}
} }
} }

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6v10c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L16,6L4,6zM17,3h-3l-1,-1L7,2L6,3L3,3v2h14L17,3z"/>
</vector>

View file

@ -1,4 +1,4 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" > <menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/> <item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
<item android:id="@+id/detail_menu_delete" android:title="@string/detail_menu_unsubscribe"/> <item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>
</menu> </menu>

View file

@ -0,0 +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"
android:icon="@drawable/baseline_delete_20"/>
</menu>

View file

@ -2,9 +2,11 @@
<resources> <resources>
<color name="primaryColor">#338574</color> <color name="primaryColor">#338574</color>
<color name="primaryLightColor">#338574</color> <color name="primaryLightColor">#338574</color>
<color name="primaryDarkColor">#338574</color> <color name="primaryDarkColor">#2A6E60</color>
<color name="primaryTextColor">#000000</color> <color name="primaryTextColor">#000000</color>
<color name="primaryLightTextColor">#FFFFFF</color> <color name="primaryLightTextColor">#FFFFFF</color>
<color name="primarySelectedRowColor">#EEEEEE</color>
</resources> </resources>

View file

@ -13,6 +13,11 @@
<string name="main_menu_source_url">https://heckel.io/ntfy-android</string> <string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
<string name="main_menu_website_title">Visit ntfy.sh</string> <string name="main_menu_website_title">Visit ntfy.sh</string>
<!-- Main activity: Action mode -->
<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>
<!-- Main activity: List and such --> <!-- Main activity: List and such -->
<string name="main_item_status_connecting">connecting …</string> <string name="main_item_status_connecting">connecting …</string>
<string name="main_item_status_reconnecting">reconnecting …</string> <string name="main_item_status_reconnecting">reconnecting …</string>
@ -35,7 +40,7 @@
<string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.</string> <string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.</string>
<string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string> <string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string>
<string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string> <string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
<string name="detail_delete_dialog_message">Do you really want to permanently delete this subscription and all its messages?</string> <string name="detail_delete_dialog_message">Do you really want to unsubscribe from this topic and delete all of the messages you received?</string>
<string name="detail_delete_dialog_permanently_delete">Permanently delete</string> <string name="detail_delete_dialog_permanently_delete">Permanently delete</string>
<string name="detail_delete_dialog_cancel">Cancel</string> <string name="detail_delete_dialog_cancel">Cancel</string>
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string> <string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>

View file

@ -4,5 +4,6 @@
<item name="colorPrimaryVariant">@color/primaryDarkColor</item> <item name="colorPrimaryVariant">@color/primaryDarkColor</item>
<item name="colorAccent">@color/primaryLightColor</item> <item name="colorAccent">@color/primaryLightColor</item>
<item name="android:statusBarColor">@color/primaryColor</item> <item name="android:statusBarColor">@color/primaryColor</item>
<item name="actionModeBackground">@color/primaryDarkColor</item>
</style> </style>
</resources> </resources>