Add divider between read and unread notifications

This commit is contained in:
Thore Goebel 2023-06-16 19:38:59 +02:00
parent 3b76564be6
commit c6ca0a9885
11 changed files with 184 additions and 59 deletions

View file

@ -108,7 +108,9 @@ data class Notification(
@ColumnInfo(name = "actions") val actions: List<Action>?, @ColumnInfo(name = "actions") val actions: List<Action>?,
@Embedded(prefix = "attachment_") val attachment: Attachment?, @Embedded(prefix = "attachment_") val attachment: Attachment?,
@ColumnInfo(name = "deleted") val deleted: Boolean, @ColumnInfo(name = "deleted") val deleted: Boolean,
) ) {
val isUnread: Boolean get() = notificationId != 0
}
@Entity @Entity
data class Attachment( data class Attachment(

View file

@ -31,13 +31,10 @@ import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.ui.detail.DetailAdapter import io.heckel.ntfy.ui.detail.*
import io.heckel.ntfy.ui.detail.DetailViewModel
import io.heckel.ntfy.ui.detail.DetailViewModelFactory
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.* import java.util.*
@ -197,17 +194,15 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
// Update main list based on viewModel (& its datasource/livedata) // Update main list based on viewModel (& its datasource/livedata)
val noEntriesText: View = findViewById(R.id.detail_no_notifications) val noEntriesText: View = findViewById(R.id.detail_no_notifications)
val onNotificationClick = { n: Notification -> onNotificationClick(n) }
val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) }
adapter = DetailAdapter(this, lifecycleScope, repository, onNotificationClick, onNotificationLongClick) adapter = DetailAdapter(this, lifecycleScope, repository, this::onNotificationItemClick, this::onNotificationItemLongClick)
mainList = findViewById(R.id.detail_notification_list) mainList = findViewById(R.id.detail_notification_list)
mainList.adapter = adapter mainList.adapter = adapter
viewModel.list(subscriptionId).observe(this) { viewModel.list(subscriptionId).observe(this) {
it?.let { it?.let {
// Show list view // Show list view
adapter.submitList(it as MutableList<Notification>) adapter.submitNotifications(it as MutableList<Notification>)
if (it.isEmpty()) { if (it.isEmpty()) {
mainListContainer.visibility = View.GONE mainListContainer.visibility = View.GONE
noEntriesText.visibility = View.VISIBLE noEntriesText.visibility = View.VISIBLE
@ -226,8 +221,19 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
return false return false
} }
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
val item = adapter.getItem(viewHolder.absoluteAdapterPosition)
if (item is UnreadDividerItem) {
return 0 // disallow swiping the unread divider
}
return super.getSwipeDirs(recyclerView, viewHolder)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {
val notification = adapter.get(viewHolder.absoluteAdapterPosition) val item = adapter.getItem(viewHolder.absoluteAdapterPosition)
if (item !is NotificationItem) {
return
}
val notification = item.notification
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
repository.markAsDeleted(notification.id) repository.markAsDeleted(notification.id)
} }
@ -627,7 +633,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
dialog.show() dialog.show()
} }
private fun onNotificationClick(notification: Notification) { private fun onNotificationItemClick(notification: Notification) {
if (actionMode != null) { if (actionMode != null) {
handleActionModeClick(notification) handleActionModeClick(notification)
} else if (notification.click != "") { } else if (notification.click != "") {
@ -652,7 +658,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
} }
} }
private fun onNotificationLongClick(notification: Notification) { private fun onNotificationItemLongClick(notification: Notification) {
if (actionMode == null) { if (actionMode == null) {
beginActionMode(notification) beginActionMode(notification)
} }
@ -660,10 +666,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private fun handleActionModeClick(notification: Notification) { private fun handleActionModeClick(notification: Notification) {
adapter.toggleSelection(notification.id) adapter.toggleSelection(notification.id)
if (adapter.selected.size == 0) { if (adapter.selectedNotificationIds.size == 0) {
finishActionMode() finishActionMode()
} else { } else {
actionMode!!.title = adapter.selected.size.toString() actionMode!!.title = adapter.selectedNotificationIds.size.toString()
} }
} }
@ -698,7 +704,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
Log.d(TAG, "Copying multiple notifications to clipboard") Log.d(TAG, "Copying multiple notifications to clipboard")
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val content = adapter.selected.joinToString("\n\n") { notificationId -> val content = adapter.selectedNotificationIds.joinToString("\n\n") { notificationId ->
val notification = repository.getNotification(notificationId) val notification = repository.getNotification(notificationId)
notification?.let { notification?.let {
decodeMessage(it) + "\n" + Date(it.timestamp * 1000).toString() decodeMessage(it) + "\n" + Date(it.timestamp * 1000).toString()
@ -723,7 +729,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
val dialog = builder val dialog = builder
.setMessage(R.string.detail_action_mode_delete_dialog_message) .setMessage(R.string.detail_action_mode_delete_dialog_message)
.setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ -> .setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ ->
adapter.selected.map { notificationId -> viewModel.markAsDeleted(notificationId) } adapter.selectedNotificationIds.map { notificationId -> viewModel.markAsDeleted(notificationId) }
finishActionMode() finishActionMode()
} }
.setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ ->
@ -759,8 +765,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private fun endActionModeAndRedraw() { private fun endActionModeAndRedraw() {
actionMode = null actionMode = null
adapter.selected.clear() adapter.clearSelection()
adapter.notifyItemRangeChanged(0, adapter.currentList.size)
// Fade status bar color // Fade status bar color
val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this)) val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))

View file

@ -5,7 +5,6 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -17,45 +16,86 @@ class DetailAdapter(
private val repository: Repository, private val repository: Repository,
private val onClick: (Notification) -> Unit, private val onClick: (Notification) -> Unit,
private val onLongClick: (Notification) -> Unit private val onLongClick: (Notification) -> Unit
) : ListAdapter<Notification, DetailViewHolder>(TopicDiffCallback) { ) : ListAdapter<DetailItem, DetailItemViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<String>() // Notification IDs
/* Creates and inflates view and return TopicViewHolder. */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailItemViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { val inflater = LayoutInflater.from(parent.context)
val view = LayoutInflater.from(parent.context) return when (viewType) {
.inflate(R.layout.fragment_detail_item, parent, false) 0 -> {
return DetailViewHolder(activity, lifecycleScope, repository, view, selected, onClick, onLongClick) val itemView = inflater.inflate(NotificationItemViewHolder.LAYOUT, parent, false)
NotificationItemViewHolder(activity, lifecycleScope, repository, onClick, onLongClick, itemView)
}
1 -> {
val itemView = inflater.inflate(UnreadDividerItemViewHolder.LAYOUT, parent, false)
UnreadDividerItemViewHolder(itemView)
}
else -> throw IllegalStateException("Unknown viewType $viewType in DetailAdapter")
}
} }
/* Gets current topic and uses it to bind view. */ override fun onBindViewHolder(holder: DetailItemViewHolder, position: Int) {
override fun onBindViewHolder(holder: DetailViewHolder, position: Int) {
holder.bind(getItem(position)) holder.bind(getItem(position))
} }
fun get(position: Int): Notification { // original method in ListAdapter is protected
return getItem(position) public override fun getItem(position: Int): DetailItem = super.getItem(position)
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is NotificationItem -> 0
is UnreadDividerItem -> 1
}
}
/* Take a list of notifications, insert the unread divider if necessary,
and call submitList for the ListAdapter to do its diff magic */
fun submitNotifications(newList: List<Notification>) {
val selectedLocal = selectedNotificationIds
val detailList: MutableList<DetailItem> = newList.map { notification ->
NotificationItem(notification, selectedLocal.contains(notification.id))
}.toMutableList()
val lastUnreadIndex = newList.indexOfLast { notification -> notification.isUnread }
if (lastUnreadIndex != -1) {
detailList.add(lastUnreadIndex + 1, UnreadDividerItem)
}
submitList(detailList.toList())
}
val selectedNotificationIds
get() = currentList
.filterIsInstance<NotificationItem>()
.filter { it.isSelected }
.map { it.notification.id }
fun clearSelection() {
currentList.forEachIndexed { index, detailItem ->
if (detailItem is NotificationItem && detailItem.isSelected) {
detailItem.isSelected = false
notifyItemChanged(index)
}
}
} }
fun toggleSelection(notificationId: String) { fun toggleSelection(notificationId: String) {
if (selected.contains(notificationId)) { currentList.forEachIndexed { index, detailItem ->
selected.remove(notificationId) if (detailItem is NotificationItem && detailItem.notification.id == notificationId) {
} else { detailItem.isSelected = !detailItem.isSelected
selected.add(notificationId) notifyItemChanged(index)
} }
if (selected.size != 0) {
val listIds = currentList.map { notification -> notification.id }
val notificationPosition = listIds.indexOf(notificationId)
notifyItemChanged(notificationPosition)
} }
} }
object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() { object TopicDiffCallback : DiffUtil.ItemCallback<DetailItem>() {
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean { override fun areItemsTheSame(oldItem: DetailItem, newItem: DetailItem): Boolean {
return oldItem.id == newItem.id return if (oldItem is NotificationItem && newItem is NotificationItem) {
oldItem.notification.id == newItem.notification.id
} else {
oldItem is UnreadDividerItem && newItem is UnreadDividerItem
}
} }
override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean { override fun areContentsTheSame(oldItem: DetailItem, newItem: DetailItem): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }

View file

@ -0,0 +1,15 @@
package io.heckel.ntfy.ui.detail
import io.heckel.ntfy.db.Notification
sealed class DetailItem
data class NotificationItem(
val notification: Notification,
var isSelected: Boolean,
) : DetailItem()
object UnreadDividerItem : DetailItem()

View file

@ -0,0 +1,9 @@
package io.heckel.ntfy.ui.detail
import android.view.View
import androidx.recyclerview.widget.RecyclerView
abstract class DetailItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
abstract fun bind(item: DetailItem)
}

View file

@ -19,7 +19,6 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.allViews import androidx.core.view.allViews
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.stfalcon.imageviewer.StfalconImageViewer import com.stfalcon.imageviewer.StfalconImageViewer
import io.heckel.ntfy.R import io.heckel.ntfy.R
@ -36,17 +35,15 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ class NotificationItemViewHolder(
class DetailViewHolder(
private val activity: Activity, private val activity: Activity,
private val lifecycleScope: CoroutineScope, private val lifecycleScope: CoroutineScope,
private val repository: Repository, private val repository: Repository,
itemView: View,
private val selected: Set<String>,
val onClick: (Notification) -> Unit, val onClick: (Notification) -> Unit,
val onLongClick: (Notification) -> Unit val onLongClick: (Notification) -> Unit,
) : RecyclerView.ViewHolder(itemView) { itemView: View
private var notification: Notification? = null ) : DetailItemViewHolder(itemView) {
private val layout: View = itemView.findViewById(R.id.detail_item_layout) private val layout: View = itemView.findViewById(R.id.detail_item_layout)
private val cardView: CardView = itemView.findViewById(R.id.detail_item_card) private val cardView: CardView = itemView.findViewById(R.id.detail_item_card)
private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image) private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image)
@ -64,8 +61,11 @@ class DetailViewHolder(
private val actionsWrapperView: ConstraintLayout = itemView.findViewById(R.id.detail_item_actions_wrapper) private val actionsWrapperView: ConstraintLayout = itemView.findViewById(R.id.detail_item_actions_wrapper)
private val actionsFlow: Flow = itemView.findViewById(R.id.detail_item_actions_flow) private val actionsFlow: Flow = itemView.findViewById(R.id.detail_item_actions_flow)
fun bind(notification: Notification) { override fun bind(item: DetailItem) {
this.notification = notification if (item !is NotificationItem) {
throw IllegalStateException("Wrong DetailItemType: $item")
}
val notification = item.notification
val context = itemView.context val context = itemView.context
val unmatchedTags = unmatchedTags(splitTags(notification.tags)) val unmatchedTags = unmatchedTags(splitTags(notification.tags))
@ -83,7 +83,7 @@ class DetailViewHolder(
messageView.setOnLongClickListener { messageView.setOnLongClickListener {
onLongClick(notification); true onLongClick(notification); true
} }
newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE newDotImageView.visibility = if (notification.isUnread) View.VISIBLE else View.GONE
cardView.setOnClickListener { onClick(notification) } cardView.setOnClickListener { onClick(notification) }
cardView.setOnLongClickListener { onLongClick(notification); true } cardView.setOnLongClickListener { onLongClick(notification); true }
if (notification.title != "") { if (notification.title != "") {
@ -94,11 +94,12 @@ class DetailViewHolder(
} }
if (unmatchedTags.isNotEmpty()) { if (unmatchedTags.isNotEmpty()) {
tagsView.visibility = View.VISIBLE tagsView.visibility = View.VISIBLE
tagsView.text = context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", ")) tagsView.text =
context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", "))
} else { } else {
tagsView.visibility = View.GONE tagsView.visibility = View.GONE
} }
if (selected.contains(notification.id)) { if (item.isSelected) {
cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context)) cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context))
} else { } else {
cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context))
@ -504,9 +505,9 @@ class DetailViewHolder(
} }
companion object { companion object {
const val TAG = "DetailViewHolder" const val TAG = "NotificationItemViewHolder"
const val LAYOUT = R.layout.item_notification_detail
const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876
const val IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap." const val IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap."
} }
} }

View file

@ -0,0 +1,16 @@
package io.heckel.ntfy.ui.detail
import android.view.View
import io.heckel.ntfy.R
class UnreadDividerItemViewHolder(itemView: View) : DetailItemViewHolder(itemView) {
override fun bind(item: DetailItem) {
// nothing to do
}
companion object {
const val LAYOUT = R.layout.item_unread_divider
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="6dp"
android:orientation="horizontal">
<View
style="@style/dividerStyle"
android:layout_width="0dp"
android:layout_height="2dp"
android:layout_gravity="center"
android:layout_marginHorizontal="12dp"
android:layout_weight="1" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="You are all caught up!" />
<View
style="@style/dividerStyle"
android:layout_width="0dp"
android:layout_height="2dp"
android:layout_gravity="center"
android:layout_marginHorizontal="12dp"
android:layout_weight="1" />
</LinearLayout>

View file

@ -38,4 +38,8 @@
<style name="CardViewBackground"> <style name="CardViewBackground">
<item name="android:background">@color/black_900</item> <item name="android:background">@color/black_900</item>
</style> </style>
<style name="dividerStyle">
<item name="android:background">@color/teal_light</item>
</style>
</resources> </resources>

View file

@ -30,4 +30,8 @@
<item name="cornerFamily">rounded</item> <item name="cornerFamily">rounded</item>
<item name="cornerSize">5dp</item> <item name="cornerSize">5dp</item>
</style> </style>
<style name="dividerStyle">
<item name="android:background">@color/teal</item>
</style>
</resources> </resources>