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

View file

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

View file

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

View file

@ -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<SubscriptionsViewModel> {
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
}
}

View file

@ -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<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. */
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<Long>, 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);
}
}
}

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

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>
<color name="primaryColor">#338574</color>
<color name="primaryLightColor">#338574</color>
<color name="primaryDarkColor">#338574</color>
<color name="primaryDarkColor">#2A6E60</color>
<color name="primaryTextColor">#000000</color>
<color name="primaryLightTextColor">#FFFFFF</color>
<color name="primarySelectedRowColor">#EEEEEE</color>
</resources>

View file

@ -13,6 +13,11 @@
<string name="main_menu_source_url">https://heckel.io/ntfy-android</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 -->
<string name="main_item_status_connecting">connecting …</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_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_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_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>

View file

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