Add messages sorting (#1262)

This commit is contained in:
Mateusz Idziejczak 2021-08-04 15:16:54 +02:00 committed by GitHub
parent d73aa605f9
commit b61e63249c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 258 additions and 63 deletions

View File

@ -4,6 +4,8 @@ import android.graphics.Typeface
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CompoundButton
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION import androidx.recyclerview.widget.RecyclerView.NO_POSITION
@ -11,60 +13,114 @@ import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Message import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.enums.MessageFolder import io.github.wulkanowy.data.enums.MessageFolder
import io.github.wulkanowy.databinding.ItemMessageBinding import io.github.wulkanowy.databinding.ItemMessageBinding
import io.github.wulkanowy.databinding.ItemMessageChipsBinding
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import javax.inject.Inject import javax.inject.Inject
class MessageTabAdapter @Inject constructor() : class MessageTabAdapter @Inject constructor() :
RecyclerView.Adapter<MessageTabAdapter.ItemViewHolder>() { RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onClickListener: (Message, position: Int) -> Unit = { _, _ -> } enum class ViewType { HEADER, ITEM }
var onItemClickListener: (Message, position: Int) -> Unit = { _, _ -> }
var onHeaderClickListener: (chip: CompoundButton, isChecked: Boolean) -> Unit = { _, _ -> }
var onChangesDetectedListener = {} var onChangesDetectedListener = {}
private var items = mutableListOf<Message>() private var items = mutableListOf<MessageTabDataItem>()
private var onlyUnread: Boolean? = null
private var onlyWithAttachments = false
fun setDataItems(data: List<Message>) { fun setDataItems(
data: List<MessageTabDataItem>,
onlyUnread: Boolean?,
onlyWithAttachments: Boolean
) {
if (items.size != data.size) onChangesDetectedListener() if (items.size != data.size) onChangesDetectedListener()
val diffResult = DiffUtil.calculateDiff(MessageTabDiffUtil(items, data)) val diffResult = DiffUtil.calculateDiff(MessageTabDiffUtil(items, data))
items = data.toMutableList() items = data.toMutableList()
this.onlyUnread = onlyUnread
this.onlyWithAttachments = onlyWithAttachments
diffResult.dispatchUpdatesTo(this) diffResult.dispatchUpdatesTo(this)
} }
override fun getItemViewType(position: Int): Int {
return when (position) {
0 -> ViewType.HEADER.ordinal
else -> ViewType.ITEM.ordinal
}
}
override fun getItemCount() = items.size override fun getItemCount() = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ItemViewHolder( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
ItemMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false) val inflater = LayoutInflater.from(parent.context)
) return when (viewType) {
ViewType.ITEM.ordinal -> ItemViewHolder(
ItemMessageBinding.inflate(inflater, parent, false)
)
ViewType.HEADER.ordinal -> HeaderViewHolder(
ItemMessageChipsBinding.inflate(inflater, parent, false)
)
else -> throw IllegalStateException()
}
}
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position] when (holder) {
is ItemViewHolder -> {
val item = (items[position] as MessageTabDataItem.MessageItem).message
with(holder.binding) { with(holder.binding) {
val style = if (item.unread) Typeface.BOLD else Typeface.NORMAL val style = if (item.unread) Typeface.BOLD else Typeface.NORMAL
messageItemAuthor.run { messageItemAuthor.run {
text = if (item.folderId == MessageFolder.SENT.id) item.recipient else item.sender text =
setTypeface(null, style) if (item.folderId == MessageFolder.SENT.id) item.recipient else item.sender
setTypeface(null, style)
}
messageItemSubject.run {
text =
if (item.subject.isNotBlank()) item.subject else context.getString(R.string.message_no_subject)
setTypeface(null, style)
}
messageItemDate.run {
text = item.date.toFormattedString()
setTypeface(null, style)
}
messageItemAttachmentIcon.visibility =
if (item.hasAttachments) View.VISIBLE else View.GONE
root.setOnClickListener {
holder.bindingAdapterPosition.let {
if (it != NO_POSITION) onItemClickListener(item, it)
}
}
}
} }
messageItemSubject.run { is HeaderViewHolder -> {
text = if (item.subject.isNotBlank()) item.subject else context.getString(R.string.message_no_subject) with(holder.binding) {
setTypeface(null, style) if (onlyUnread == null) chipUnread.isVisible = false
} else {
messageItemDate.run { chipUnread.isVisible = true
text = item.date.toFormattedString() chipUnread.isChecked = onlyUnread!!
setTypeface(null, style) chipUnread.setOnCheckedChangeListener(onHeaderClickListener)
} }
messageItemAttachmentIcon.visibility = if (item.hasAttachments) View.VISIBLE else View.GONE chipAttachments.isChecked = onlyWithAttachments
chipAttachments.setOnCheckedChangeListener(onHeaderClickListener)
root.setOnClickListener { }
holder.bindingAdapterPosition.let { if (it != NO_POSITION) onClickListener(item, it) }
} }
} }
} }
class ItemViewHolder(val binding: ItemMessageBinding) : RecyclerView.ViewHolder(binding.root) class ItemViewHolder(val binding: ItemMessageBinding) : RecyclerView.ViewHolder(binding.root)
class HeaderViewHolder(val binding: ItemMessageChipsBinding) :
RecyclerView.ViewHolder(binding.root)
private class MessageTabDiffUtil(private val old: List<Message>, private val new: List<Message>) : private class MessageTabDiffUtil(
private val old: List<MessageTabDataItem>,
private val new: List<MessageTabDataItem>
) :
DiffUtil.Callback() { DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size override fun getOldListSize(): Int = old.size

View File

@ -0,0 +1,15 @@
package io.github.wulkanowy.ui.modules.message.tab
import io.github.wulkanowy.data.db.entities.Message
sealed class MessageTabDataItem {
data class MessageItem(val message: Message) : MessageTabDataItem() {
override val id = message.id
}
object Header : MessageTabDataItem() {
override val id = Long.MIN_VALUE
}
abstract val id: Long
}

View File

@ -7,6 +7,7 @@ import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.widget.CompoundButton
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -48,6 +49,10 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
override val isViewEmpty override val isViewEmpty
get() = tabAdapter.itemCount == 0 get() = tabAdapter.itemCount == 0
override var onlyUnread: Boolean? = false
override var onlyWithAttachments = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -58,26 +63,33 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentMessageTabBinding.bind(view) binding = FragmentMessageTabBinding.bind(view)
messageContainer = binding.messageTabRecycler messageContainer = binding.messageTabRecycler
presenter.onAttachView(this, MessageFolder.valueOf(
(savedInstanceState ?: arguments)?.getString(MESSAGE_TAB_FOLDER_ID).orEmpty() val folder = MessageFolder.valueOf(
)) (savedInstanceState ?: requireArguments()).getString(MESSAGE_TAB_FOLDER_ID).orEmpty()
)
presenter.onAttachView(this, folder)
} }
override fun initView() { override fun initView() {
with(tabAdapter) { with(tabAdapter) {
onClickListener = presenter::onMessageItemSelected onItemClickListener = presenter::onMessageItemSelected
onHeaderClickListener = ::onChipChecked
onChangesDetectedListener = ::resetListPosition onChangesDetectedListener = ::resetListPosition
} }
with(binding.messageTabRecycler) { with(binding.messageTabRecycler) {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
adapter = tabAdapter adapter = tabAdapter
addItemDecoration(DividerItemDecoration(context)) addItemDecoration(DividerItemDecoration(context, false))
} }
with(binding) { with(binding) {
messageTabSwipe.setOnRefreshListener(presenter::onSwipeRefresh) messageTabSwipe.setOnRefreshListener(presenter::onSwipeRefresh)
messageTabSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary)) messageTabSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary))
messageTabSwipe.setProgressBackgroundColorSchemeColor(requireContext().getThemeAttrColor(R.attr.colorSwipeRefresh)) messageTabSwipe.setProgressBackgroundColorSchemeColor(
requireContext().getThemeAttrColor(
R.attr.colorSwipeRefresh
)
)
messageTabErrorRetry.setOnClickListener { presenter.onRetry() } messageTabErrorRetry.setOnClickListener { presenter.onRetry() }
messageTabErrorDetails.setOnClickListener { presenter.onDetailsClick() } messageTabErrorDetails.setOnClickListener { presenter.onDetailsClick() }
} }
@ -99,8 +111,9 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
}) })
} }
override fun updateData(data: List<Message>) { override fun updateData(data: List<MessageTabDataItem>, hide: Boolean) {
tabAdapter.setDataItems(data) if (hide) onlyUnread = null
tabAdapter.setDataItems(data, onlyUnread, onlyWithAttachments)
} }
override fun showProgress(show: Boolean) { override fun showProgress(show: Boolean) {
@ -143,8 +156,19 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
(parentFragment as? MessageFragment)?.onChildFragmentLoaded() (parentFragment as? MessageFragment)?.onChildFragmentLoaded()
} }
fun onParentLoadData(forceRefresh: Boolean) { fun onParentLoadData(
presenter.onParentViewLoadData(forceRefresh) forceRefresh: Boolean,
onlyUnread: Boolean? = this.onlyUnread,
onlyWithAttachments: Boolean = this.onlyWithAttachments
) {
presenter.onParentViewLoadData(forceRefresh, onlyUnread, onlyWithAttachments)
}
private fun onChipChecked(chip: CompoundButton, isChecked: Boolean) {
when (chip.id) {
R.id.chip_unread -> presenter.onUnreadFilterSelected(isChecked)
R.id.chip_attachments -> presenter.onAttachmentsFilterSelected(isChecked)
}
} }
fun onParentDeleteMessage() { fun onParentDeleteMessage() {

View File

@ -55,15 +55,15 @@ class MessageTabPresenter @Inject constructor(
fun onSwipeRefresh() { fun onSwipeRefresh() {
Timber.i("Force refreshing the $folder message") Timber.i("Force refreshing the $folder message")
onParentViewLoadData(true) view?.run { onParentViewLoadData(true, onlyUnread, onlyWithAttachments) }
} }
fun onRetry() { fun onRetry() {
view?.run { view?.run {
showErrorView(false) showErrorView(false)
showProgress(true) showProgress(true)
loadData(true, onlyUnread == true, onlyWithAttachments)
} }
loadData(true)
} }
fun onDetailsClick() { fun onDetailsClick() {
@ -71,11 +71,15 @@ class MessageTabPresenter @Inject constructor(
} }
fun onDeleteMessage() { fun onDeleteMessage() {
loadData(true) view?.run { loadData(true, onlyUnread == true, onlyWithAttachments) }
} }
fun onParentViewLoadData(forceRefresh: Boolean) { fun onParentViewLoadData(
loadData(forceRefresh) forceRefresh: Boolean,
onlyUnread: Boolean? = view?.onlyUnread,
onlyWithAttachments: Boolean = view?.onlyWithAttachments == true
) {
loadData(forceRefresh, onlyUnread == true, onlyWithAttachments)
} }
fun onMessageItemSelected(message: Message, position: Int) { fun onMessageItemSelected(message: Message, position: Int) {
@ -83,7 +87,25 @@ class MessageTabPresenter @Inject constructor(
view?.openMessage(message) view?.openMessage(message)
} }
private fun loadData(forceRefresh: Boolean) { fun onUnreadFilterSelected(isChecked: Boolean) {
view?.run {
onlyUnread = isChecked
onParentViewLoadData(false, onlyUnread, onlyWithAttachments)
}
}
fun onAttachmentsFilterSelected(isChecked: Boolean) {
view?.run {
onlyWithAttachments = isChecked
onParentViewLoadData(false, onlyUnread, onlyWithAttachments)
}
}
private fun loadData(
forceRefresh: Boolean,
onlyUnread: Boolean,
onlyWithAttachments: Boolean
) {
Timber.i("Loading $folder message data started") Timber.i("Loading $folder message data started")
flowWithResourceIn { flowWithResourceIn {
@ -100,7 +122,15 @@ class MessageTabPresenter @Inject constructor(
showProgress(false) showProgress(false)
showContent(true) showContent(true)
messages = it.data messages = it.data
updateData(getFilteredData(lastSearchQuery)) val filteredData = getFilteredData(
lastSearchQuery,
onlyUnread,
onlyWithAttachments
)
val newItems = listOf(MessageTabDataItem.Header) + filteredData.map {
MessageTabDataItem.MessageItem(it)
}
updateData(newItems, folder.id == MessageFolder.SENT.id)
notifyParentDataLoaded() notifyParentDataLoaded()
} }
} }
@ -108,7 +138,7 @@ class MessageTabPresenter @Inject constructor(
Status.SUCCESS -> { Status.SUCCESS -> {
Timber.i("Loading $folder message result: Success") Timber.i("Loading $folder message result: Success")
messages = it.data!! messages = it.data!!
updateData(getFilteredData(lastSearchQuery)) updateData(getFilteredData(lastSearchQuery, onlyUnread, onlyWithAttachments))
analytics.logEvent( analytics.logEvent(
"load_data", "load_data",
"type" to "messages", "type" to "messages",
@ -166,24 +196,42 @@ class MessageTabPresenter @Inject constructor(
} }
} }
private fun getFilteredData(query: String): List<Message> { private fun getFilteredData(
return if (query.trim().isEmpty()) { query: String,
messages.sortedByDescending { it.date } onlyUnread: Boolean = false,
onlyWithAttachments: Boolean = false
): List<Message> {
if (query.trim().isEmpty()) {
val sortedMessages = messages.sortedByDescending { it.date }
return when {
onlyUnread && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments }
onlyUnread -> sortedMessages.filter { it.unread == onlyUnread }
onlyWithAttachments -> sortedMessages.filter { it.hasAttachments == onlyWithAttachments }
else -> sortedMessages
}
} else { } else {
messages val sortedMessages = messages
.map { it to calculateMatchRatio(it, query) } .map { it to calculateMatchRatio(it, query) }
.sortedByDescending { it.second } .sortedWith(compareBy<Pair<Message, Int>> { -it.second }.thenByDescending { it.first.date })
.filter { it.second > 5000 } .filter { it.second > 6000 }
.map { it.first } .map { it.first }
return when {
onlyUnread && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments }
onlyUnread -> sortedMessages.filter { it.unread == onlyUnread }
onlyWithAttachments -> sortedMessages.filter { it.hasAttachments == onlyWithAttachments }
else -> sortedMessages
}
} }
} }
private fun updateData(data: List<Message>) { private fun updateData(data: List<Message>) {
view?.run { view?.run {
showEmpty(data.isEmpty()) showEmpty(data.isEmpty())
showContent(data.isNotEmpty()) showContent(true)
showErrorView(false) showErrorView(false)
updateData(data) val newItems =
listOf(MessageTabDataItem.Header) + data.map { MessageTabDataItem.MessageItem(it) }
updateData(newItems, folder.id == MessageFolder.SENT.id)
} }
} }
@ -204,14 +252,6 @@ class MessageTabPresenter @Inject constructor(
FuzzySearch.ratio( FuzzySearch.ratio(
query.lowercase(), query.lowercase(),
message.date.toFormattedString("dd.MM.yyyy").lowercase() message.date.toFormattedString("dd.MM.yyyy").lowercase()
),
FuzzySearch.ratio(
query.lowercase(),
message.date.toFormattedString("d MMMM").lowercase()
),
FuzzySearch.ratio(
query.lowercase(),
message.date.toFormattedString("d MMMM yyyy").lowercase()
) )
).maxOrNull() ?: 0 ).maxOrNull() ?: 0

View File

@ -7,11 +7,15 @@ interface MessageTabView : BaseView {
val isViewEmpty: Boolean val isViewEmpty: Boolean
var onlyUnread: Boolean?
var onlyWithAttachments: Boolean
fun initView() fun initView()
fun resetListPosition() fun resetListPosition()
fun updateData(data: List<Message>) fun updateData(data: List<MessageTabDataItem>, hide: Boolean)
fun showProgress(show: Boolean) fun showProgress(show: Boolean)

View File

@ -5,7 +5,10 @@ import android.graphics.Canvas
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
class DividerItemDecoration(context: Context) : DividerItemDecoration(context, VERTICAL) { class DividerItemDecoration(
context: Context,
private val showDividerWithFirstItem: Boolean = true
) : DividerItemDecoration(context, VERTICAL) {
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
canvas.save() canvas.save()
@ -13,6 +16,8 @@ class DividerItemDecoration(context: Context) : DividerItemDecoration(context, V
val dividerRight = parent.width - parent.paddingRight val dividerRight = parent.width - parent.paddingRight
for (i in 0..parent.childCount - 2) { for (i in 0..parent.childCount - 2) {
if (!showDividerWithFirstItem && i == 0) continue
val child = parent.getChildAt(i) val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams val params = child.layoutParams as RecyclerView.LayoutParams
val dividerTop = child.bottom + params.bottomMargin val dividerTop = child.bottom + params.bottomMargin

View File

@ -0,0 +1,40 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/messageChipsLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingTop="10dp"
android:paddingRight="16dp"
tools:context=".ui.modules.message.tab.MessageTabAdapter">
<com.google.android.material.chip.ChipGroup
android:id="@+id/messageChipGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.chip.Chip
android:id="@+id/chip_unread"
style="@style/Widget.MaterialComponents.Chip.Choice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/message_chip_only_unread"
app:checkedIcon="@drawable/ic_mtrl_chip_checked_black"
app:checkedIconEnabled="true"
app:checkedIconTint="@color/mtrl_choice_chip_text_color" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_attachments"
style="@style/Widget.MaterialComponents.Chip.Choice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/message_chip_only_with_attachments"
app:checkedIcon="@drawable/ic_mtrl_chip_checked_black"
app:checkedIconEnabled="true"
app:checkedIconTint="@color/mtrl_choice_chip_text_color" />
</com.google.android.material.chip.ChipGroup>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -234,6 +234,8 @@
<string name="message_not_exists">Message does not exist</string> <string name="message_not_exists">Message does not exist</string>
<string name="message_required_recipients">You need to choose at least 1 recipient</string> <string name="message_required_recipients">You need to choose at least 1 recipient</string>
<string name="message_content_min_length">The message content must be at least 3 characters</string> <string name="message_content_min_length">The message content must be at least 3 characters</string>
<string name="message_chip_only_unread">Only unread</string>
<string name="message_chip_only_with_attachments">Only with attachments</string>
<plurals name="message_number_item"> <plurals name="message_number_item">
<item quantity="one">%d message</item> <item quantity="one">%d message</item>
<item quantity="other">%d messages</item> <item quantity="other">%d messages</item>

View File

@ -36,6 +36,15 @@
<item name="android:textColor">?android:textColorPrimary</item> <item name="android:textColor">?android:textColorPrimary</item>
</style> </style>
<style name="Widget.Wulkanowy.Chip.Choice" parent="Widget.MaterialComponents.Chip.Choice">
...
<item name="materialThemeOverlay">@style/ThemeOverlay.Wulkanowy.Chip.Choice</item>
</style>
<style name="ThemeOverlay.Wulkanowy.Chip.Choice" parent="">
<item name="elevationOverlayEnabled">false</item>
</style>
<style name="WulkanowyTheme.TextAppearanceBottomNavigation"> <style name="WulkanowyTheme.TextAppearanceBottomNavigation">
<item name="android:textSize">11sp</item> <item name="android:textSize">11sp</item>
</style> </style>