Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {

implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/otus/gpb/recyclerview/Chat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package otus.gpb.recyclerview

import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes

data class Chat(
val id: Long,
val title: String,
val message: String,
val time: String,
val avatarLetters: String,
@ColorInt val avatarColor: Int,
@DrawableRes val avatarResId: Int? = null,
val unreadCount: Int = 0,
val isVerified: Boolean = false,
val isMuted: Boolean = false,
val isPinned: Boolean = false,
val isOnline: Boolean = false,
val hasMention: Boolean = false,
val deliveryState: DeliveryState = DeliveryState.NONE,
val messageState: MessageState = MessageState.REGULAR,
) {
enum class DeliveryState { NONE, SENT, READ }
enum class MessageState { REGULAR, DRAFT, TYPING }
}
96 changes: 96 additions & 0 deletions app/src/main/java/otus/gpb/recyclerview/ChatAdapter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package otus.gpb.recyclerview

import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView

class ChatAdapter : ListAdapter<Chat, ChatAdapter.ChatViewHolder>(DiffCallback) {

init {
setHasStableIds(true)
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}

override fun getItemId(position: Int): Long = getItem(position).id

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_chat, parent, false)
return ChatViewHolder(view)
}

override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
holder.bind(getItem(position))
}

class ChatViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val avatarImage = itemView.findViewById<ImageView>(R.id.avatarImage)
private val avatarInitials = itemView.findViewById<TextView>(R.id.avatarInitials)
private val online = itemView.findViewById<View>(R.id.onlineIndicator)
private val title = itemView.findViewById<TextView>(R.id.chatTitle)
private val verified = itemView.findViewById<ImageView>(R.id.verifiedBadge)
private val muted = itemView.findViewById<ImageView>(R.id.mutedBadge)
private val message = itemView.findViewById<TextView>(R.id.lastMessage)
private val typingDots = itemView.findViewById<ImageView>(R.id.typingDots)
private val time = itemView.findViewById<TextView>(R.id.chatTime)
private val delivery = itemView.findViewById<View>(R.id.deliveryState)
private val firstCheck = itemView.findViewById<ImageView>(R.id.firstCheck)
private val unread = itemView.findViewById<TextView>(R.id.unreadBadge)
private val pinned = itemView.findViewById<ImageView>(R.id.pinnedBadge)
private val mention = itemView.findViewById<ImageView>(R.id.mentionBadge)
private val trailingStatus = itemView.findViewById<View>(R.id.trailingStatus)

fun bind(chat: Chat) {
avatarImage.isVisible = chat.avatarResId != null
avatarInitials.isVisible = chat.avatarResId == null
chat.avatarResId?.let(avatarImage::setImageResource)
avatarInitials.text = chat.avatarLetters
avatarInitials.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(chat.avatarColor)
}

online.isVisible = chat.isOnline
title.text = chat.title
verified.isVisible = chat.isVerified
muted.isVisible = chat.isMuted
time.text = chat.time

val isTyping = chat.messageState == Chat.MessageState.TYPING
typingDots.isVisible = isTyping
message.text = chat.message
message.setTextColor(
ContextCompat.getColor(
itemView.context,
when (chat.messageState) {
Chat.MessageState.TYPING -> R.color.telegram_typing
Chat.MessageState.DRAFT -> R.color.telegram_red
Chat.MessageState.REGULAR -> R.color.text_secondary
},
),
)

delivery.isVisible = chat.deliveryState != Chat.DeliveryState.NONE
firstCheck.isVisible = chat.deliveryState == Chat.DeliveryState.READ

val hasUnread = chat.unreadCount > 0 && !chat.hasMention
unread.isVisible = hasUnread
unread.text = itemView.resources.getString(R.string.unread_count, chat.unreadCount)
mention.isVisible = chat.hasMention
pinned.isVisible = chat.isPinned && !hasUnread && !chat.hasMention
trailingStatus.isVisible = hasUnread || chat.hasMention || pinned.isVisible
}
}

private object DiffCallback : DiffUtil.ItemCallback<Chat>() {
override fun areItemsTheSame(oldItem: Chat, newItem: Chat) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Chat, newItem: Chat) = oldItem == newItem
}
}
25 changes: 25 additions & 0 deletions app/src/main/java/otus/gpb/recyclerview/ChatDividerDecoration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package otus.gpb.recyclerview

import android.graphics.Canvas
import android.graphics.Paint
import androidx.recyclerview.widget.RecyclerView

class ChatDividerDecoration(
dividerColor: Int,
private val startInset: Int,
private val height: Int,
) : RecyclerView.ItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = dividerColor }

override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val end = parent.width - parent.paddingRight
for (index in 0 until parent.childCount) {
val child = parent.getChildAt(index)
val adapterPosition = parent.getChildAdapterPosition(child)
if (adapterPosition == RecyclerView.NO_POSITION || adapterPosition >= state.itemCount - 1) continue
val decoratedBottom = parent.layoutManager?.getDecoratedBottom(child) ?: child.bottom
val top = decoratedBottom + child.translationY.toInt()
canvas.drawRect(startInset.toFloat(), top.toFloat(), end.toFloat(), (top + height).toFloat(), paint)
}
}
}
68 changes: 68 additions & 0 deletions app/src/main/java/otus/gpb/recyclerview/ChatFactory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package otus.gpb.recyclerview

import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import java.util.Locale

object ChatFactory {
private data class Sample(
val title: String,
val message: String,
val initials: String,
@ColorInt val color: Int,
@DrawableRes val avatarResId: Int? = null,
val verified: Boolean = false,
val muted: Boolean = false,
val pinned: Boolean = false,
val online: Boolean = false,
val mention: Boolean = false,
val unread: Int = 0,
val delivery: Chat.DeliveryState = Chat.DeliveryState.NONE,
val state: Chat.MessageState = Chat.MessageState.REGULAR,
)

private val samples = listOf(
Sample("Dima Murantsev", "Всегда пожалуйста :)", "DM", 0xFFD89B68.toInt(), R.drawable.avatar_dima, verified = true, pinned = true, delivery = Chat.DeliveryState.READ),
Sample("Оффтопка", "А в вашем инфоне это есть?", "ОФ", 0xFF78A9C4.toInt(), R.drawable.avatar_offtopic, muted = true, pinned = true, online = true),
Sample("Catbird", "печатает", "CB", 0xFF7C6AB0.toInt(), R.drawable.avatar_catbird, pinned = true, state = Chat.MessageState.TYPING),
Sample("just design", "Вы: я хочу пить", "JD", 0xFF9193E8.toInt(), R.drawable.avatar_just_design, muted = true, mention = true, delivery = Chat.DeliveryState.READ),
Sample("R4INB0W", "du biest mein sonnchein", "R4", 0xFF454D68.toInt(), R.drawable.avatar_r4inbow),
Sample("Да. Нет.", "Красиво", "ДН", 0xFF3994C6.toInt(), R.drawable.avatar_yes_no, verified = true, unread = 50, delivery = Chat.DeliveryState.SENT),
Sample("SMDDN", "Сними с меня скам!", "SM", 0xFF5B7EA6.toInt(), R.drawable.avatar_smddn),
Sample("Android Developers", "RecyclerView умеет больше, чем кажется", "AD", 0xFF3DDC84.toInt(), verified = true, unread = 3),
Sample("Учебная группа", "Задание сдано на проверку", "УГ", 0xFFF2A65A.toInt(), muted = true, unread = 12),
Sample("Saved Messages", "Макет Telegram", "SM", 0xFF4AA3DF.toInt(), delivery = Chat.DeliveryState.READ),
)

fun createPage(page: Int, pageSize: Int = 10): List<Chat> = List(pageSize) { index ->
val absoluteIndex = page * pageSize + index
val sample = samples[absoluteIndex % samples.size]
val cycle = absoluteIndex / samples.size
sample.toChat(
id = absoluteIndex.toLong(),
suffix = if (cycle == 0) "" else " ${cycle + 1}",
minutesAgo = absoluteIndex % 60,
)
}

private fun Sample.toChat(id: Long, suffix: String, minutesAgo: Int): Chat {
val totalMinutes = 11 * 60 + 38 + minutesAgo
return Chat(
id = id,
title = title + suffix,
message = message,
time = String.format(Locale.US, "%02d:%02d", totalMinutes / 60, totalMinutes % 60),
avatarLetters = initials,
avatarColor = color,
avatarResId = avatarResId,
unreadCount = unread,
isVerified = verified,
isMuted = muted,
isPinned = pinned,
isOnline = online,
hasMention = mention,
deliveryState = delivery,
messageState = state,
)
}
}
77 changes: 77 additions & 0 deletions app/src/main/java/otus/gpb/recyclerview/ChatSwipeCallback.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package otus.gpb.recyclerview

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView

class ChatSwipeCallback(
context: Context,
private val onDismissed: (chatId: Long) -> Unit,
) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
private val density = context.resources.displayMetrics.density
private val archiveIcon: Drawable = requireNotNull(
ContextCompat.getDrawable(context, R.drawable.ic_archive),
).mutate()
private val archiveLabel = context.getString(R.string.archive)
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.swipe_archive)
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = context.resources.getDimension(R.dimen.swipe_label_size)
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
}

override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
) = false

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
if (viewHolder.itemId != RecyclerView.NO_ID) onDismissed(viewHolder.itemId)
}

override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder) = 0.7f

override fun onChildDraw(
canvas: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean,
) {
val item = viewHolder.itemView
if (dX < 0f) {
canvas.drawRect(
item.right + dX,
item.top.toFloat(),
item.right.toFloat(),
item.bottom.toFloat(),
backgroundPaint,
)

val centerX = item.right - 41f * density
val centerY = (item.top + item.bottom) / 2f
val iconSize = (30f * density).toInt()
val iconCenterY = centerY - 9f * density
archiveIcon.setBounds(
(centerX - iconSize / 2f).toInt(),
(iconCenterY - iconSize / 2f).toInt(),
(centerX + iconSize / 2f).toInt(),
(iconCenterY + iconSize / 2f).toInt(),
)
archiveIcon.draw(canvas)
canvas.drawText(archiveLabel, centerX, centerY + 28f * density, textPaint)
}
super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
}
Loading