diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 5f39796..856f112 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -108,7 +108,9 @@ data class Notification( @ColumnInfo(name = "actions") val actions: List?, @Embedded(prefix = "attachment_") val attachment: Attachment?, @ColumnInfo(name = "deleted") val deleted: Boolean, -) +) { + val isUnread: Boolean get() = notificationId != 0 +} @Entity data class Attachment( 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 f19d87b..a6dcef8 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -31,13 +31,10 @@ import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.firebase.FirebaseMessenger -import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.service.SubscriberServiceManager -import io.heckel.ntfy.ui.detail.DetailAdapter -import io.heckel.ntfy.ui.detail.DetailViewModel -import io.heckel.ntfy.ui.detail.DetailViewModelFactory +import io.heckel.ntfy.ui.detail.* import io.heckel.ntfy.util.* import kotlinx.coroutines.* import java.util.* @@ -197,17 +194,15 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra // Update main list based on viewModel (& its datasource/livedata) 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.adapter = adapter viewModel.list(subscriptionId).observe(this) { it?.let { // Show list view - adapter.submitList(it as MutableList) + adapter.submitNotifications(it as MutableList) if (it.isEmpty()) { mainListContainer.visibility = View.GONE 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 { 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) { - 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) { repository.markAsDeleted(notification.id) } @@ -627,7 +633,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra dialog.show() } - private fun onNotificationClick(notification: Notification) { + private fun onNotificationItemClick(notification: Notification) { if (actionMode != null) { handleActionModeClick(notification) } 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) { beginActionMode(notification) } @@ -660,10 +666,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra private fun handleActionModeClick(notification: Notification) { adapter.toggleSelection(notification.id) - if (adapter.selected.size == 0) { + if (adapter.selectedNotificationIds.size == 0) { finishActionMode() } 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") 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) notification?.let { decodeMessage(it) + "\n" + Date(it.timestamp * 1000).toString() @@ -723,7 +729,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra val dialog = builder .setMessage(R.string.detail_action_mode_delete_dialog_message) .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() } .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> @@ -759,8 +765,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra private fun endActionModeAndRedraw() { actionMode = null - adapter.selected.clear() - adapter.notifyItemRangeChanged(0, adapter.currentList.size) + adapter.clearSelection() // Fade status bar color val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this)) diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt index b2041b8..dd58f7d 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt @@ -5,7 +5,6 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import io.heckel.ntfy.R import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import kotlinx.coroutines.CoroutineScope @@ -17,45 +16,86 @@ class DetailAdapter( private val repository: Repository, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit -) : ListAdapter(TopicDiffCallback) { - val selected = mutableSetOf() // Notification IDs +) : ListAdapter(TopicDiffCallback) { - /* Creates and inflates view and return TopicViewHolder. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.fragment_detail_item, parent, false) - return DetailViewHolder(activity, lifecycleScope, repository, view, selected, onClick, onLongClick) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailItemViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + 0 -> { + 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: DetailViewHolder, position: Int) { + override fun onBindViewHolder(holder: DetailItemViewHolder, position: Int) { holder.bind(getItem(position)) } - fun get(position: Int): Notification { - return getItem(position) + // original method in ListAdapter is protected + 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) { + val selectedLocal = selectedNotificationIds + val detailList: MutableList = 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() + .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) { - if (selected.contains(notificationId)) { - selected.remove(notificationId) - } else { - selected.add(notificationId) - } - - if (selected.size != 0) { - val listIds = currentList.map { notification -> notification.id } - val notificationPosition = listIds.indexOf(notificationId) - notifyItemChanged(notificationPosition) + currentList.forEachIndexed { index, detailItem -> + if (detailItem is NotificationItem && detailItem.notification.id == notificationId) { + detailItem.isSelected = !detailItem.isSelected + notifyItemChanged(index) + } } } - object TopicDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean { - return oldItem.id == newItem.id + object TopicDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DetailItem, newItem: DetailItem): Boolean { + 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 } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItem.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItem.kt new file mode 100644 index 0000000..bd8b406 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItem.kt @@ -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() diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItemViewHolder.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItemViewHolder.kt new file mode 100644 index 0000000..e0147cd --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItemViewHolder.kt @@ -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) +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/NotificationItemViewHolder.kt similarity index 97% rename from app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt rename to app/src/main/java/io/heckel/ntfy/ui/detail/NotificationItemViewHolder.kt index 633e451..a0791c7 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/NotificationItemViewHolder.kt @@ -19,7 +19,6 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.view.allViews -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import com.stfalcon.imageviewer.StfalconImageViewer import io.heckel.ntfy.R @@ -36,17 +35,15 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ -class DetailViewHolder( +class NotificationItemViewHolder( private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, - itemView: View, - private val selected: Set, val onClick: (Notification) -> Unit, - val onLongClick: (Notification) -> Unit -) : RecyclerView.ViewHolder(itemView) { - private var notification: Notification? = null + val onLongClick: (Notification) -> Unit, + itemView: View +) : DetailItemViewHolder(itemView) { + private val layout: View = itemView.findViewById(R.id.detail_item_layout) private val cardView: CardView = itemView.findViewById(R.id.detail_item_card) 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 actionsFlow: Flow = itemView.findViewById(R.id.detail_item_actions_flow) - fun bind(notification: Notification) { - this.notification = notification + override fun bind(item: DetailItem) { + if (item !is NotificationItem) { + throw IllegalStateException("Wrong DetailItemType: $item") + } + val notification = item.notification val context = itemView.context val unmatchedTags = unmatchedTags(splitTags(notification.tags)) @@ -83,7 +83,7 @@ class DetailViewHolder( messageView.setOnLongClickListener { 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.setOnLongClickListener { onLongClick(notification); true } if (notification.title != "") { @@ -94,11 +94,12 @@ class DetailViewHolder( } if (unmatchedTags.isNotEmpty()) { 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 { tagsView.visibility = View.GONE } - if (selected.contains(notification.id)) { + if (item.isSelected) { cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context)) } else { cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) @@ -504,9 +505,9 @@ class DetailViewHolder( } 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 IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap." } - } diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/UnreadDividerItemViewHolder.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/UnreadDividerItemViewHolder.kt new file mode 100644 index 0000000..44edbcb --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/UnreadDividerItemViewHolder.kt @@ -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 + } +} diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/item_notification_detail.xml similarity index 100% rename from app/src/main/res/layout/fragment_detail_item.xml rename to app/src/main/res/layout/item_notification_detail.xml diff --git a/app/src/main/res/layout/item_unread_divider.xml b/app/src/main/res/layout/item_unread_divider.xml new file mode 100644 index 0000000..c24a067 --- /dev/null +++ b/app/src/main/res/layout/item_unread_divider.xml @@ -0,0 +1,29 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index a71491b..3999d74 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -38,4 +38,8 @@ + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4f0902d..1f9a655 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -30,4 +30,8 @@ rounded 5dp + +