diff --git a/app/build.gradle b/app/build.gradle index 54e4eac..2d0be58 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,7 +4,7 @@ plugins { } android { - compileSdk 34 + compileSdk 35 namespace "otus.gpb.recyclerview" defaultConfig { @@ -30,14 +30,18 @@ android { kotlinOptions { jvmTarget = '1.8' } + buildFeatures { + viewBinding = true + } } dependencies { - + implementation('com.github.bumptech.glide:glide:4.16.0') implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.5.1' implementation 'com.google.android.material:material:1.7.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.compose.ui:ui-graphics-android:1.8.3' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef75335..9ed9ff2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + = + (groupChats.map { ChatItem.Group(it) } + + userChats.map { ChatItem.User(it) }).toMutableList() + + private lateinit var binding: ActivityMainBinding + private lateinit var adapter: ChatAdapter + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.recyclerView.addItemDecoration(CustomDecorator(this).apply { + setColor(R.color.color_chat_divider_light) + setOffset(R.integer.dividerOffset) + }) + + binding.recyclerView.layoutManager = LinearLayoutManager(this) + adapter = ChatAdapter() { id -> + println(id) + } + + binding.recyclerView.adapter = adapter + + adapter.submitList(chatItems.toList()) + + val itemTouchHelper = ItemTouchHelper( + SwipeToDeleteCallback ({ position -> removeItem(position)}, this) + ) + itemTouchHelper.attachToRecyclerView(binding.recyclerView) + } + + private fun removeItem(position: Int) { + chatItems.removeAt(position) + adapter.submitList(chatItems.toList()) + } + } \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/data/ChatItem.kt b/app/src/main/java/otus/gpb/recyclerview/data/ChatItem.kt new file mode 100644 index 0000000..44ee951 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/data/ChatItem.kt @@ -0,0 +1,6 @@ +package otus.gpb.recyclerview.data + +sealed class ChatItem() { + data class Group(val chat: GroupChat) : ChatItem() + data class User(val chat: UserChat) : ChatItem() +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/data/GroupChat.kt b/app/src/main/java/otus/gpb/recyclerview/data/GroupChat.kt new file mode 100644 index 0000000..46c7e87 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/data/GroupChat.kt @@ -0,0 +1,81 @@ +package otus.gpb.recyclerview.data + +data class GroupChat( + val id: Int, + val groupName: String, + val lastUsername: String, + val lastMessage: String, + val avatarUrl: String, + val lastUserUrl: String, + var isVerified: Boolean, + var isMuted: Boolean, + var isRead: Boolean, + var isVoip: Boolean, + var counterMessages: Int, + var isMentioned: Boolean, + val messageTime: String +) + + +val groupChats = listOf( + GroupChat( + id = 1, + groupName = "Labubu fans", + lastUsername = "Teodor", + lastMessage = "Hi, how are you?", + avatarUrl = "https://media.geeksforgeeks.org/wp-content/uploads/20210101144014/gfglogo.png", + lastUserUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Moscow_%288351271825%29.jpg/500px-Moscow_%288351271825%29.jpg", + isVerified = false, + isMuted = false, + isRead = true, + isVoip = false, + counterMessages = 0, + isMentioned = false, + messageTime = "06.07.2025 10:00", + ), + GroupChat( + id = 2, + groupName = "Top Gear", + lastUsername = "Patrick", + lastMessage = "Yes, I know this, but what you want to do?", + avatarUrl = "https://media.geeksforgeeks.org/wp-content/uploads/20210101144014/gfglogo.png", + lastUserUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Moscow_%288351271825%29.jpg/500px-Moscow_%288351271825%29.jpg", + isVerified = false, + isMuted = false, + isRead = true, + isVoip = false, + counterMessages = 0, + isMentioned = false, + messageTime = "10.06.2025 15:23", + ), + GroupChat( + id = 3, + groupName = "Karena Makarena", + lastUsername = "Tereza", + lastMessage = "Whaaaaaaat??", + avatarUrl = "https://i.imgur.com/DvpvklR.png", + lastUserUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Moscow_%288351271825%29.jpg/500px-Moscow_%288351271825%29.jpg", + isVerified = false, + isMuted = false, + isRead = false, + isVoip = true, + counterMessages = 3, + isMentioned = false, + messageTime = "06.07.2025 10:00", + ), + GroupChat( + id = 4, + groupName = "Eric Davidich", + lastUsername = "Magomed", + lastMessage = "Shaize", + avatarUrl = "https://i.imgur.com/DvpvklR.png", + lastUserUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Moscow_%288351271825%29.jpg/500px-Moscow_%288351271825%29.jpg", + isVerified = false, + isMuted = true, + isRead = false, + isVoip = false, + counterMessages = 69, + isMentioned = true, + messageTime = "05.07.2025 17:56", + ), +) \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/data/UserChat.kt b/app/src/main/java/otus/gpb/recyclerview/data/UserChat.kt new file mode 100644 index 0000000..e2eca6f --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/data/UserChat.kt @@ -0,0 +1,57 @@ +package otus.gpb.recyclerview.data + +data class UserChat( + val id: Int, + val username: String, + val lastMessage: String, + val avatarUrl: String, + var isVerified: Boolean, + var isMuted: Boolean, + var isRead: Boolean, + var isScam: Boolean, + var isOnline: Boolean, + var counterMessages: Int, + val messageTime: String +) + +val userChats = listOf( + UserChat( + id = 1, + username = "Павел Дуров", + lastMessage = "Пойдём сегодня на футбол?", + avatarUrl = "https://upload.wikimedia.org/wikipedia/commons/3/33/Espaguetis_carbonara.jpg", + isVerified = true, + isMuted = false, + isRead = false, + isScam = false, + isOnline = true, + counterMessages = 1, + messageTime = "06.07.2025 13:05" + ), + UserChat( + id = 2, + username = "Бадави", + lastMessage = "Сроооочно зайди да", + avatarUrl = "https://upload.wikimedia.org/wikipedia/commons/3/33/Espaguetis_carbonara.jpg", + isVerified = false, + isMuted = true, + isRead = false, + isScam = false, + isOnline = false, + counterMessages = 10, + messageTime = "02.07.2025 13:46" + ), + UserChat( + id = 3, + username = "Олег Тинькофф", + lastMessage = "Скинь 3 цифры на обороте карты", + avatarUrl = "https://upload.wikimedia.org/wikipedia/commons/3/33/Espaguetis_carbonara.jpg", + isVerified = false, + isMuted = false, + isRead = false, + isScam = true, + isOnline = true, + counterMessages = 5, + messageTime = "25.01.2024 16:18" + ), +) diff --git a/app/src/main/java/otus/gpb/recyclerview/ui/ChatAdapter.kt b/app/src/main/java/otus/gpb/recyclerview/ui/ChatAdapter.kt new file mode 100644 index 0000000..31a4945 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ui/ChatAdapter.kt @@ -0,0 +1,101 @@ +package otus.gpb.recyclerview.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import otus.gpb.recyclerview.data.ChatItem +import otus.gpb.recyclerview.databinding.ItemGroupChatBinding +import otus.gpb.recyclerview.databinding.ItemUserChatBinding +import java.util.Locale + +class ChatAdapter(private val onItemClick: (Int) -> Unit): + ListAdapter(ChatDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + + return when(viewType){ + VIEW_TYPE_GROUP -> { + val binding = ItemGroupChatBinding.inflate(inflater, parent, false) + GroupChatViewHolder(binding) + } + VIEW_TYPE_USER -> { + val binding = ItemUserChatBinding.inflate(inflater, parent, false) + UserChatViewHolder(binding) + } + else -> throw RuntimeException("RuntimeException") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = getItem(position)) { + is ChatItem.Group -> (holder as GroupChatViewHolder).bind(item) + is ChatItem.User -> (holder as UserChatViewHolder).bind(item) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)){ + is ChatItem.Group -> VIEW_TYPE_GROUP + is ChatItem.User -> VIEW_TYPE_USER + } + } + + inner class GroupChatViewHolder(private val binding: ItemGroupChatBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: ChatItem.Group) { + binding.groupName.text = item.chat.groupName + binding.lastUsername.text = item.chat.lastUsername + Glide.with(binding.root.context) + .load(item.chat.avatarUrl) + .centerCrop() + .into(binding.avatarUrl) + binding.lastMessage.text = item.chat.lastMessage + binding.verification.visibility = if (item.chat.isVerified) View.VISIBLE else View.GONE + binding.mute.visibility = if (item.chat.isMuted){ + binding.badge.isActivated = true + View.VISIBLE + } else { + binding.badge.isActivated = false + View.GONE + } + binding.voip.visibility = if (item.chat.isVoip) View.VISIBLE else View.GONE + + binding.badge.visibility = if (item.chat.isMentioned) View.VISIBLE else View.GONE + binding.badge.text = String.format(Locale("ru", "RU"), "%d", item.chat.counterMessages) + binding.messageTime.text = item.chat.messageTime + binding.root.setOnClickListener { onItemClick(item.chat.id) } + } + } + + inner class UserChatViewHolder(private val binding: ItemUserChatBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: ChatItem.User) { + binding.usernameField.text = item.chat.username + Glide.with(binding.root.context) + .load(item.chat.avatarUrl) + .centerCrop() + .into(binding.avatarUrl) + binding.lastMessage.text = item.chat.lastMessage + binding.verification.visibility = if (item.chat.isVerified) View.VISIBLE else View.GONE + binding.mute.visibility = if (item.chat.isMuted){ + binding.badge.isActivated = true + View.VISIBLE + } else { + binding.badge.isActivated = false + View.GONE + } + binding.scam.visibility = if (item.chat.isScam) View.VISIBLE else View.GONE + binding.badge.visibility = if (item.chat.counterMessages>0) View.VISIBLE else View.GONE + binding.badge.text = String.format(Locale("ru", "RU"), "%d", item.chat.counterMessages) + binding.messageTime.text = item.chat.messageTime + binding.root.setOnClickListener { onItemClick(item.chat.id) } + } + } + + companion object{ + private const val VIEW_TYPE_GROUP = 1 + private const val VIEW_TYPE_USER = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ui/ChatDiffCallback.kt b/app/src/main/java/otus/gpb/recyclerview/ui/ChatDiffCallback.kt new file mode 100644 index 0000000..368d842 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ui/ChatDiffCallback.kt @@ -0,0 +1,19 @@ +package otus.gpb.recyclerview.ui + +import androidx.recyclerview.widget.DiffUtil +import otus.gpb.recyclerview.data.ChatItem + +class ChatDiffCallback : DiffUtil.ItemCallback() { + + override fun areContentsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { + return oldItem == newItem + } + + override fun areItemsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { + return when{ + oldItem is ChatItem.Group && newItem is ChatItem.Group -> oldItem.chat.id == newItem.chat.id + oldItem is ChatItem.User && newItem is ChatItem.User -> oldItem.chat.id == newItem.chat.id + else -> false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ui/CustomDecorator.kt b/app/src/main/java/otus/gpb/recyclerview/ui/CustomDecorator.kt new file mode 100644 index 0000000..8f4edfd --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ui/CustomDecorator.kt @@ -0,0 +1,50 @@ +package otus.gpb.recyclerview.ui + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView + +class CustomDecorator(private val context: Context) : DividerItemDecoration(context, VERTICAL){ + + private val bounds = Rect() + private val paint = Paint() + private var offset = 0 + private var color = 0xFF000000.toInt() + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + paint.color = color + + val childCount = parent.childCount + for (index: Int in 0 until childCount) { + val child = parent.getChildAt(index) + parent.getDecoratedBoundsWithMargins(child, bounds) + bounds.left += offset + + val positionCurrent = parent.getChildAdapterPosition(child) + if (positionCurrent != RecyclerView.NO_POSITION) { + val lastElementPosition = parent.adapter?.itemCount?.minus(1) + if (positionCurrent != lastElementPosition) { + c.drawLine( + (bounds.left).toFloat(), + bounds.bottom.toFloat(), + bounds.right.toFloat(), + bounds.bottom.toFloat(), + paint + ) + } + } + } + } + + fun setColor(id: Int) { + color = context.getColor(id) + } + + fun setOffset(id: Int) { + offset = (context.resources.getInteger(id) * Resources.getSystem().displayMetrics.density).toInt() + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ui/SwipeToDeleteCallback.kt b/app/src/main/java/otus/gpb/recyclerview/ui/SwipeToDeleteCallback.kt new file mode 100644 index 0000000..5bc5d44 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ui/SwipeToDeleteCallback.kt @@ -0,0 +1,73 @@ +package otus.gpb.recyclerview.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import otus.gpb.recyclerview.R + +class SwipeToDeleteCallback( + private val onItemRemoved: (Int) -> Unit, + context: Context +) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + + private val deleteIcon = ContextCompat.getDrawable(context, R.drawable.ic_delete)!! + private val backgroundPaint = Paint().apply { + color = 0xFFE64646.toInt() + } + + override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { + return 0.85f + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder) = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + onItemRemoved(viewHolder.adapterPosition) + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + val itemView = viewHolder.itemView + val itemHeight = itemView.bottom - itemView.top + + if (dX != 0f) { + val backgroundRect = if (dX > 0) { + RectF(itemView.left.toFloat(), itemView.top.toFloat(), itemView.left + dX, itemView.bottom.toFloat()) + } else { + RectF(itemView.right + dX, itemView.top.toFloat(), itemView.right.toFloat(), itemView.bottom.toFloat()) + } + c.drawRect(backgroundRect, backgroundPaint) + } + + val icon = deleteIcon + val iconTop = itemView.top + (itemHeight - icon.intrinsicHeight) / 2 + + if (dX > 0) { + val iconLeft = itemView.left + dX.toInt() - icon.intrinsicWidth - 32 + val iconRight = iconLeft + icon.intrinsicWidth + icon.setBounds(iconLeft, iconTop, iconRight, iconTop + icon.intrinsicHeight) + icon.draw(c) + } else if (dX < 0) { + val iconRight = itemView.right + dX.toInt() + icon.intrinsicWidth + 32 + val iconLeft = iconRight - icon.intrinsicWidth + icon.setBounds(iconLeft, iconTop, iconRight, iconTop + icon.intrinsicHeight) + icon.draw(c) + } + + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } + +} + diff --git a/app/src/main/res/drawable/circle_shape_blue.xml b/app/src/main/res/drawable/circle_shape_blue.xml new file mode 100644 index 0000000..cca1cd9 --- /dev/null +++ b/app/src/main/res/drawable/circle_shape_blue.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_avatar.png b/app/src/main/res/drawable/ic_avatar.png new file mode 100644 index 0000000..1f5e89d Binary files /dev/null and b/app/src/main/res/drawable/ic_avatar.png differ diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..3d6007e --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_mention.xml b/app/src/main/res/drawable/ic_mention.xml new file mode 100644 index 0000000..ca45d79 --- /dev/null +++ b/app/src/main/res/drawable/ic_mention.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_mute.xml b/app/src/main/res/drawable/ic_mute.xml new file mode 100644 index 0000000..ca48517 --- /dev/null +++ b/app/src/main/res/drawable/ic_mute.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_scam.xml b/app/src/main/res/drawable/ic_scam.xml new file mode 100644 index 0000000..f6d9704 --- /dev/null +++ b/app/src/main/res/drawable/ic_scam.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_vector_mention.xml b/app/src/main/res/drawable/ic_vector_mention.xml new file mode 100644 index 0000000..6e60993 --- /dev/null +++ b/app/src/main/res/drawable/ic_vector_mention.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_verified.xml b/app/src/main/res/drawable/ic_verified.xml new file mode 100644 index 0000000..52ec8f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_verified.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_voipe.xml b/app/src/main/res/drawable/ic_voipe.xml new file mode 100644 index 0000000..c2b0bbd --- /dev/null +++ b/app/src/main/res/drawable/ic_voipe.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/shape_avatar_imageview.xml b/app/src/main/res/drawable/shape_avatar_imageview.xml new file mode 100644 index 0000000..a9a5983 --- /dev/null +++ b/app/src/main/res/drawable/shape_avatar_imageview.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_group_chat.xml b/app/src/main/res/layout/item_group_chat.xml new file mode 100644 index 0000000..1dbc18a --- /dev/null +++ b/app/src/main/res/layout/item_group_chat.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_user_chat.xml b/app/src/main/res/layout/item_user_chat.xml new file mode 100644 index 0000000..150bc07 --- /dev/null +++ b/app/src/main/res/layout/item_user_chat.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..5fbf9d4 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,6 @@ #FF018786 #FF000000 #FFFFFFFF + + #FFD9D9D9 \ No newline at end of file diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml new file mode 100644 index 0000000..d19e039 --- /dev/null +++ b/app/src/main/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 73 + \ No newline at end of file