diff --git a/app/build.gradle b/app/build.gradle index 54e4eac..833401f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,6 +17,10 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { + viewBinding = true + } + buildTypes { release { minifyEnabled false @@ -41,4 +45,8 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + + // Glide для оптимизированной загрузки изображений + implementation 'com.github.bumptech.glide:glide:4.16.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef75335..227131a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + Unit) : + ListAdapter(ChatDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (ViewTypes.fromId(viewType)) { + ViewTypes.GROUP -> { + val binding = ViewHolderGroupBinding.inflate(LayoutInflater.from(parent.context), parent, false) + GroupChatViewHolder(binding) + } + ViewTypes.PERSON -> { + val binding = ViewHolderPersonBinding.inflate(LayoutInflater.from(parent.context), parent, false) + PersonChatViewHolder(binding) + } + null -> throw IllegalArgumentException("Unknown view type: $viewType") + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is GroupChat -> ViewTypes.GROUP.id + is PersonChat -> ViewTypes.PERSON.id + else -> throw IllegalArgumentException("Unknown item type: ${getItem(position)}") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + // привязка данных с проверкой типа и границ + if (position < 0 || position >= itemCount) return + + try { + when (holder) { + is GroupChatViewHolder -> { + val item = getItem(position) + if (item is GroupChat) { + holder.bind(item) + } + } + is PersonChatViewHolder -> { + val item = getItem(position) + if (item is PersonChat) { + holder.bind(item) + } + } + } + resetViewAppearance(holder.itemView) + } catch (e: Exception) { + // логируем ошибку и сбрасываем внешний вид View + e.printStackTrace() + resetViewAppearance(holder.itemView) + } + } + + fun removeItem(position: Int) { + if (position >= 0 && position < currentList.size) { + val newList = currentList.toMutableList().apply { removeAt(position) } + submitList(newList) + } + } + + private fun resetViewAppearance(view: View) { + view.alpha = 1f + view.scaleX = 1f + view.scaleY = 1f + } + + inner class GroupChatViewHolder(private val binding: ViewHolderGroupBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: GroupChat) { + // кэшируем часто используемые View + with(binding) { + // Аватар с оптимизацией + loadAvatar(item.avatarUrl, imgGroupAvatar, R.drawable.group_badge) + // бейджи и иконки + imgVoipBadgeContainer.visibility = item.voip.toVisibility() + imgVerifiedIcon.visibility = item.verified.toVisibility() + imgMuteIcon.visibility = item.muted.toVisibility() + // тексты + txtGroupName.text = item.groupName + txtLastAuthor.text = item.lastAuthor + txtLastMessage.text = item.lastMessage + txtTimeValue.text = item.time + // preview сообщения + setupMessagePreview(item.messagePreviewUrl, imgMessagePreviewContainer, imgMessagePreview) + // статусы прочтения + setupReadStatus(item.checked, item.read, imgCheckedIcon, imgReadIcon) + // счетчик + setupCounter(item.counter, imgCounterContainer, txtCounterContainer) + // дополнительные иконки + imgPinnedIcon.visibility = item.pinned.toVisibility() + imgMentionIconContainer.visibility = item.mentioned.toVisibility() + + root.setOnClickListener { onItemClick(item.id) } + } + } + } + + inner class PersonChatViewHolder(private val binding: ViewHolderPersonBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: PersonChat) { + with(binding) { + // аватар + loadAvatar(item.avatarUrl, imgPersonAvatar, R.drawable.person_badge) + // бейджи статусов + setupPersonBadges(item.checkbox, item.online, item.locked) + // тексты + txtPersonName.text = item.personName + txtLastMessage.text = item.lastMessage + txtTimeValue.text = item.time + // иконки верификации и scam + setupVerificationAndScam(item.verified, item.scam) + imgMuteIcon.visibility = item.muted.toVisibility() + // preview сообщения с оптимизацией + setupMessagePreview(item.messagePreviewUrl, imgMessagePreviewContainer, imgMessagePreview) + // статусы прочтения + setupReadStatus(item.checked, item.read, imgCheckedIcon, imgReadIcon) + // счётчик + setupCounter(item.counter, imgCounterContainer, txtCounterContainer) + // дополнительные иконки + imgPinnedIcon.visibility = item.pinned.toVisibility() + imgMentionIconContainer.visibility = item.mentioned.toVisibility() + + root.setOnClickListener { onItemClick(item.id) } + } + } + + private fun setupPersonBadges(checkbox: Boolean, online: Boolean, locked: Boolean) { + with(binding) { + imgCheckboxBadgeContainer.visibility = (checkbox && !online).toVisibility() + imgOnlineBadgeContainer.visibility = online.toVisibility() + imgLockedIcon.visibility = locked.toVisibility() + } + } + + private fun setupVerificationAndScam(verified: Boolean, scam: Boolean) { + with(binding) { + imgScamPatch.visibility = scam.toVisibility() + imgVerifiedIcon.visibility = (verified && !scam).toVisibility() + } + } + } + + // extension functions для загрузки изображений (эксперимент с оптимизацией) + private fun loadAvatar(avatarUrl: String, imageView: ImageView, placeholder: Int) { + if (avatarUrl.isNotEmpty()) { + Glide.with(imageView.context) + .load(avatarUrl) + .centerCrop() + .placeholder(placeholder) + .error(placeholder) // Добавляем error placeholder + .diskCacheStrategy(com.bumptech.glide.load.engine.DiskCacheStrategy.ALL) // Кэшируем все + .skipMemoryCache(false) // Используем память для быстрого доступа + .thumbnail(0.1f) // Показываем уменьшенную версию сначала + .into(imageView) + } else { + imageView.setImageResource(placeholder) + } + } + + private fun setupMessagePreview(previewUrl: String, container: View, imageView: ImageView) { + if (previewUrl.isNotEmpty()) { + container.visibility = View.VISIBLE + Glide.with(imageView.context) + .load(previewUrl) + .centerCrop() + .diskCacheStrategy(com.bumptech.glide.load.engine.DiskCacheStrategy.ALL) + .skipMemoryCache(false) + .thumbnail(0.1f) + .into(imageView) + } else { + container.visibility = View.GONE + } + } + + private fun setupReadStatus(checked: Boolean, read: Boolean, checkedIcon: ImageView, readIcon: ImageView) { + checkedIcon.visibility = (checked && !read).toVisibility() + readIcon.visibility = read.toVisibility() + } + + private fun setupCounter(counter: Int, container: View, counterText: TextView) { + if (counter == 0) { + container.visibility = View.GONE + } else { + container.visibility = View.VISIBLE + counterText.text = counter.toString() + } + } + + private fun Boolean.toVisibility() = if (this) View.VISIBLE else View.GONE + + enum class ViewTypes(val id: Int) { + GROUP(R.layout.view_holder_group), + PERSON(R.layout.view_holder_person); + + companion object { + fun fromId(id: Int) = ViewTypes.entries.find { it.id == id } + } + } +} + +class ChatDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { + // сравнение содержимого для избежания лишних перерисовок + return when { + oldItem is GroupChat && newItem is GroupChat -> { + oldItem.groupName == newItem.groupName && + oldItem.lastAuthor == newItem.lastAuthor && + oldItem.lastMessage == newItem.lastMessage && + oldItem.avatarUrl == newItem.avatarUrl && + oldItem.messagePreviewUrl == newItem.messagePreviewUrl && + oldItem.voip == newItem.voip && + oldItem.verified == newItem.verified && + oldItem.muted == newItem.muted && + oldItem.time == newItem.time && + oldItem.checked == newItem.checked && + oldItem.read == newItem.read && + oldItem.mentioned == newItem.mentioned && + oldItem.pinned == newItem.pinned && + oldItem.counter == newItem.counter + } + oldItem is PersonChat && newItem is PersonChat -> { + oldItem.personName == newItem.personName && + oldItem.lastMessage == newItem.lastMessage && + oldItem.avatarUrl == newItem.avatarUrl && + oldItem.messagePreviewUrl == newItem.messagePreviewUrl && + oldItem.checkbox == newItem.checkbox && + oldItem.online == newItem.online && + oldItem.locked == newItem.locked && + oldItem.scam == newItem.scam && + oldItem.verified == newItem.verified && + oldItem.muted == newItem.muted && + oldItem.time == newItem.time && + oldItem.checked == newItem.checked && + oldItem.read == newItem.read && + oldItem.mentioned == newItem.mentioned && + oldItem.pinned == newItem.pinned && + oldItem.counter == newItem.counter + } + else -> false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatItem.kt b/app/src/main/java/otus/gpb/recyclerview/ChatItem.kt new file mode 100644 index 0000000..fe34b3e --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatItem.kt @@ -0,0 +1,5 @@ +package otus.gpb.recyclerview + +interface ChatItem { + val id: Int +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/CustomDecorator.kt b/app/src/main/java/otus/gpb/recyclerview/CustomDecorator.kt new file mode 100644 index 0000000..c88596f --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/CustomDecorator.kt @@ -0,0 +1,51 @@ +package otus.gpb.recyclerview + +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) { //73 + 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/GroupChat.kt b/app/src/main/java/otus/gpb/recyclerview/GroupChat.kt new file mode 100644 index 0000000..2eb7af5 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/GroupChat.kt @@ -0,0 +1,192 @@ +package otus.gpb.recyclerview + +data class GroupChat ( + override var id: Int, + val groupName: String, + val lastAuthor: String, + val lastMessage: String, + val avatarUrl: String, + val messagePreviewUrl: String, + var voip: Boolean, + var verified: Boolean, + var muted: Boolean, + val time: String, + var checked: Boolean, + var read: Boolean, + var mentioned: Boolean, + var pinned: Boolean, + var counter: Int +) : ChatItem + +val groupChatList = listOf( + GroupChat( + id = 1, + groupName = "Новости Уренгоя", + lastAuthor = "Рустам", + lastMessage = "Ямы в асфальте", + avatarUrl = "https://upload.wikimedia.org/wikipedia/commons/0/07/Coat_of_Arms_of_Novy_Urengoy_%28Yamal_Nenetsia%29.png", + messagePreviewUrl = "https://s.ura.news/1200_900/images/news/upload/news/563/455/1052563455/61347_Yama_v_asfalyte_Chelyabinsk_yama_na_asfalyte_250x0_3920.2208.0.0.jpg", + voip = true, + verified = false, + muted = true, + time = "12:01", + checked = false, + read = false, + mentioned = true, + pinned = true, + counter = 0 + ), + GroupChat( + id = 2, + groupName = "Наука и техника", + lastAuthor = "Наука 2.0", + lastMessage = "Сегоня русскими учёными был открыт..", + avatarUrl = "https://img.freepik.com/free-vector/cheerful-professor-giving-lecture_1308-166876.jpg", + messagePreviewUrl = "", + voip = true, + verified = false, + muted = true, + time = "23:08", + checked = false, + read = true, + mentioned = true, + pinned = false, + counter = 0 + ), + GroupChat( + id = 3, + groupName = "Космос", + lastAuthor = "admin", + lastMessage = "Космическое пространство, космос...", + avatarUrl = "https://abrakadabra.fun/uploads/posts/2022-02/1644750437_4-abrakadabra-fun-p-kosmicheskie-avatarki-10.jpg", + messagePreviewUrl = "", + voip = false, + verified = false, + muted = true, + time = "16:22", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 0 + ), + GroupChat( + id = 4, + groupName = "Растения России", + lastAuthor = "админ", + lastMessage = "В Россия вы можете найти Тысячелистник...", + avatarUrl = "https://www.picturethisai.com/image-handle/website_cmsname/image/1080/201229495676567552.jpeg?x-oss-process=image/format,webp", + messagePreviewUrl = "", + voip = false, + verified = true, + muted = false, + time = "14:56", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 50 + ), + GroupChat( + id = 5, + groupName = "Быстрее всех", + lastAuthor = "Руслан", + lastMessage = "Кто хочет стать миллионером?", + avatarUrl = "https://info.sibnet.ru/ni/285/285233b.jpg", + messagePreviewUrl = "", + voip = false, + verified = true, + muted = false, + time = "16:08", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 312 + ), + GroupChat( + id = 6, + groupName = "Собаки и кошки", + lastAuthor = "Женя", + lastMessage = "Коротко о братьях меньших..", + avatarUrl = "https://www.kadrof.ru/sites/default/files/illustrations/cat_dogs_0_0.jpg", + messagePreviewUrl = "", + voip = false, + verified = true, + muted = false, + time = "Вт", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 1 + ), + GroupChat( + id = 7, + groupName = "Гоночные машины", + lastAuthor = "Олег", + lastMessage = "машина как пушка!", + avatarUrl = "https://avtorinok.ru/storage/news/pics/2011/19684.jpg", + messagePreviewUrl = "", + voip = false, + verified = true, + muted = false, + time = "14:56", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 5 + ), + GroupChat( + id = 8, + groupName = "Памяти предков", + lastAuthor = "Аркадий", + lastMessage = "Тогда, под Сталниградом..", + avatarUrl = "https://stihi.ru/pics/2016/05/20/6502.jpg", + messagePreviewUrl = "", + voip = false, + verified = true, + muted = false, + time = "14:56", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 14 + ), + GroupChat( + id = 9, + groupName = "Звезда", + lastAuthor = "Генерал", + lastMessage = "Тонкая грань между..", + avatarUrl = "https://avatarko.ru/img/avatar/2/prazdnik_zvezda_1360.jpg", + messagePreviewUrl = "", + voip = false, + verified = true, + muted = false, + time = "00:14", + checked = true, + read = true, + mentioned = false, + pinned = false, + counter = 4 + ), + GroupChat( + id = 10, + groupName = "Мисс Египет", + lastAuthor = "Ра", + lastMessage = "...она была недовольна!", + avatarUrl = "https://i.pinimg.com/236x/95/be/a6/95bea63153271f1ea054eacf99ba85c2.jpg", + messagePreviewUrl = "", + voip = false, + verified = true, + muted = false, + time = "Чт", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 2 + ) +) \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt b/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt index e2cdca7..0c1b122 100644 --- a/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt +++ b/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt @@ -1,12 +1,300 @@ package otus.gpb.recyclerview +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import otus.gpb.recyclerview.databinding.ActivityMainBinding +import kotlin.random.Random +import androidx.core.graphics.drawable.toDrawable +import kotlin.math.abs class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var adapter: ChatAdapter + private var chatItems: List = emptyList() + + // Кэшируем Random для оптимизации производительности + private val random = Random(System.currentTimeMillis()) + + // Счетчик для генерации уникальных ID + private var nextId = 1 + 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) + }) + + // Настройка RecyclerView с оптимизацией производительности + binding.recyclerView.layoutManager = LinearLayoutManager(this) + + // Настройка RecycledViewPool для переиспользования ViewHolder + val viewPool = RecyclerView.RecycledViewPool().apply { + setMaxRecycledViews(ChatAdapter.ViewTypes.GROUP.id, 15) // Увеличиваем пул для групп + setMaxRecycledViews(ChatAdapter.ViewTypes.PERSON.id, 15) // Увеличиваем пул для персон + } + binding.recyclerView.setRecycledViewPool(viewPool) + + // Включаем оптимизацию для стабильных ID + binding.recyclerView.setHasFixedSize(true) + + // Отключаем анимацию по умолчанию для лучшей производительности + binding.recyclerView.itemAnimator = null + + // Включаем кэширование для лучшей производительности + binding.recyclerView.setItemViewCacheSize(20) + + adapter = ChatAdapter() { id -> + println(id) + } + + binding.recyclerView.adapter = adapter + ItemTouchHelper( + ItemTouchHelperCallback( + adapter, + this + ) + ).attachToRecyclerView(binding.recyclerView) + + + + // Инициализируем nextId после загрузки начальных данных + chatItems = generateList(isInitial = true) + nextId = chatItems.maxOfOrNull { it.id }?.plus(1) ?: 1 + adapter.submitList(chatItems) + + // Оптимизированный scroll listener для предотвращения пропуска кадров + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + private var isLoading = false + private val pageSize = 20 // Размер страницы для пагинации + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + // Проверяем только при прокрутке вниз и если не загружаем уже + if (dy > 0 && !isLoading) { + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val totalItemCount = layoutManager.itemCount + val lastVisible = layoutManager.findLastVisibleItemPosition() + + // Загружаем новые элементы заранее, когда остается 5 элементов + if (lastVisible >= totalItemCount - 5) { + isLoading = true + loadMoreItems() + } + } + } + + private fun loadMoreItems() { + // Используем post для выполнения в следующем цикле UI + binding.recyclerView.post { + val newItems = generateList(isInitial = false) + chatItems = chatItems + newItems + adapter.submitList(chatItems) { + isLoading = false + } + } + } + }) + } + + private fun getRandomBool() = random.nextInt(0, 2) > 0 + + @SuppressLint("DefaultLocale") + private fun getRandomTime(): String { + val hour = random.nextInt(0, 24) + val min = random.nextInt(0, 60) + return String.format("%d:%02d", hour, min) + } + + private fun generateRandomMessage(): String { + val messages = listOf( + "Привет! Как дела?", + "Посмотри на это фото", + "Что думаешь об этом?", + "Интересная новость", + "Встречаемся завтра?", + "Спасибо за помощь", + "Отличная идея!", + "Не могу поверить", + "Это потрясающе!", + "Давай обсудим", + "Нужна твоя помощь", + "Как прошла встреча?", + "Всё готово", + "Отлично получилось", + "Не забудь про завтра" + ) + return messages[random.nextInt(messages.size)] + } + + private fun randomizeGroupChat(item: GroupChat): GroupChat { + return GroupChat( + id = nextId++, // Генерируем уникальный ID + groupName = "${item.groupName} ${random.nextInt(1, 1000)}", // Добавляем номер для уникальности + lastAuthor = item.lastAuthor, + lastMessage = generateRandomMessage(), + avatarUrl = item.avatarUrl, + messagePreviewUrl = if (random.nextInt(0, 3) == 0) item.messagePreviewUrl else "", + voip = getRandomBool(), + verified = getRandomBool(), + muted = getRandomBool(), + time = getRandomTime(), + checked = getRandomBool(), + read = getRandomBool(), + mentioned = getRandomBool(), + pinned = getRandomBool(), + counter = if (random.nextInt(0, 10) > 0) 0 else { + random.nextInt(1, 199) + } + ) + } + + private fun randomizePersonChat(item: PersonChat): PersonChat { + return PersonChat( + id = nextId++, // Генерируем уникальный ID + personName = "${item.personName} ${random.nextInt(1, 1000)}", // Добавляем номер для уникальности + lastMessage = generateRandomMessage(), + avatarUrl = item.avatarUrl, + messagePreviewUrl = if (random.nextInt(0, 3) == 0) item.messagePreviewUrl else "", + checkbox = getRandomBool(), + online = getRandomBool(), + locked = getRandomBool(), + scam = if (random.nextInt(0, 10) > 0) false else getRandomBool(), + verified = getRandomBool(), + muted = getRandomBool(), + time = getRandomTime(), + checked = getRandomBool(), + read = getRandomBool(), + mentioned = getRandomBool(), + pinned = getRandomBool(), + counter = if (random.nextInt(0, 10) > 0) 0 else { + random.nextInt(1, 19) + } + ) } + + private fun generateList(isInitial: Boolean = false): List { + val pairs = minOf(personChatList.size, groupChatList.size) + + return if (isInitial) { + // Для начальной загрузки используем оригинальные данные + (0 until pairs).flatMap { i -> + listOf(personChatList[i], groupChatList[i]) + } + } else { + // Для пагинации генерируем новые элементы с уникальными ID + (0 until pairs).flatMap { i -> + listOf( + randomizePersonChat(personChatList[i]), + randomizeGroupChat(groupChatList[i]) + ) + } + } + } + + class ItemTouchHelperCallback( + private val adapter: ChatAdapter, + private val context: Context + ) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { + + private var background: ColorDrawable? = null + private var archiveIcon: Drawable? = null + private var textPaint: Paint = Paint().apply { + textSize = 40f + color = ContextCompat.getColor(context, android.R.color.white) + textAlign = Paint.Align.CENTER + isAntiAlias = true + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val position = viewHolder.adapterPosition + if (position != RecyclerView.NO_POSITION) { + adapter.removeItem(position) + } + } + + 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 (background == null) { + background = ContextCompat.getColor(context, R.color.color_chat_background_light) + .toDrawable() + } + + if (archiveIcon == null) { + archiveIcon = ContextCompat.getDrawable(context, R.drawable.icon_archive) + } + + if (dX < 0) { // Свайп влево + // Рисуем подложку + background?.apply { + setBounds( + itemView.right + dX.toInt(), + itemView.top, + itemView.right, + itemView.bottom + ) + draw(c) + } + + if (abs(dX) > 0) { + archiveIcon?.apply { + val iconMargin = (itemHeight - intrinsicHeight) / 2 + val iconTop = itemView.top + iconMargin + val iconBottom = iconTop + intrinsicHeight + val iconLeft = itemView.right - iconMargin - intrinsicWidth + val iconRight = itemView.right - iconMargin + + setBounds(iconLeft, iconTop, iconRight, iconBottom) + draw(c) + + val textX = (iconLeft + iconRight) / 2f // Центр иконки по X + val textY = iconBottom + 40f // Под иконкой с отступом + + c.drawText( + context.getString(R.string.archive_text_for_icon), + textX, + textY, + textPaint + ) + } + } + } + + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } + } + + } \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/PersonChat.kt b/app/src/main/java/otus/gpb/recyclerview/PersonChat.kt new file mode 100644 index 0000000..9e0d033 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/PersonChat.kt @@ -0,0 +1,204 @@ +package otus.gpb.recyclerview + +data class PersonChat( + override var id: Int, + val personName: String, + val lastMessage: String, + val avatarUrl: String, + val messagePreviewUrl: String = "", + var checkbox: Boolean, + var online: Boolean, + var locked: Boolean, + var scam: Boolean, + var verified: Boolean, + var muted: Boolean, + val time: String, + var checked: Boolean, + var read: Boolean, + var mentioned: Boolean, + var pinned: Boolean, + var counter: Int +) : ChatItem + +val personChatList = listOf( + PersonChat( + id = 1, + personName = "Дима Булыгин", + lastMessage = "Как дела?", + avatarUrl = "https://99px.ru/cms/temp/module_temp1/image1_image_111102521392452924164x64.jpg", + checkbox = false, + online = true, + locked = false, + scam = false, + verified = true, + muted = false, + time = "Ср", + checked = true, + read = false, + mentioned = false, + pinned = true, + counter = 0 + ), + PersonChat( + id = 2, + personName = "Оксана Политковская", + lastMessage = "Ничего не бойся, я с тобой", + avatarUrl = "https://shapka-youtube.ru/wp-content/uploads/2021/03/kartinka-na-avatarku-dlya-devushek.jpg", + checkbox = false, + online = true, + locked = false, + scam = false, + verified = true, + muted = false, + time = "Ср", + checked = true, + read = false, + mentioned = false, + pinned = true, + counter = 0 + ), + PersonChat( + id = 3, + personName = "Олег Кошевой", + lastMessage = "Будь моложе", + avatarUrl = "https://99px.ru/sstorage/1/2025/08/image_11508251524124353275.jpg", + checkbox = false, + online = false, + locked = false, + scam = false, + verified = false, + muted = false, + time = "Вт", + checked = true, + read = false, + mentioned = false, + pinned = true, + counter = 0 + ), + PersonChat( + id = 4, + personName = "Павлик Дуров", + lastMessage = "Блин, я в тюрьме, в Париже..", + avatarUrl = "https://images.techinsider.ru/upload/img_cache/c61/c61b042b8f2cb917787a0968a8753d52_cropped_510x340.webp", + checkbox = false, + online = true, + locked = true, + scam = true, + verified = false, + muted = true, + time = "06:43", + checked = false, + read = false, + mentioned = false, + pinned = false, + counter = 999 + ), + PersonChat( + id = 5, + personName = "Баста", + lastMessage = "Йо, братан, как дела?", + avatarUrl = "https://cdn.kassir.ru/krd/poster/8e/8e58de873fcd911b8ac6c2ea20f17062.jpg", + checkbox = false, + online = false, + locked = false, + scam = false, + verified = true, + muted = false, + time = "15:01", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 0 + ), + PersonChat( + id = 6, + personName = "CatBird", + lastMessage = "Что ты будешь делать?", + avatarUrl = "https://img.freepik.com/premium-vector/vector-hand-drawn-doodle-sketch-catbird-bird_982534-5454.jpg", + checkbox = false, + online = false, + locked = false, + scam = false, + verified = true, + muted = false, + time = "Вс", + checked = false, + read = false, + mentioned = false, + pinned = true, + counter = 0 + ), + PersonChat( + id = 7, + personName = "Moonlight", + lastMessage = "Сегодня ночью дождь", + avatarUrl = "https://img.freepik.com/premium-vector/white-moon-cartoon-with-star-sky_43633-3705.jpg", + checkbox = false, + online = true, + locked = false, + scam = false, + verified = true, + muted = false, + time = "Ср", + checked = true, + read = false, + mentioned = false, + pinned = true, + counter = 0 + ), + PersonChat( + id = 8, + personName = "trololo", + lastMessage = "Ахахаха", + avatarUrl = "https://i.pinimg.com/736x/da/a8/5b/daa85b83a914eeaef3151dd019f207bf.jpg", + checkbox = false, + online = false, + locked = false, + scam = false, + verified = false, + muted = false, + time = "Вт", + checked = true, + read = false, + mentioned = false, + pinned = true, + counter = 0 + ), + PersonChat( + id = 9, + personName = "Oleg", + lastMessage = "Кто тут?", + avatarUrl = "https://cdn.dprofile.ru/public/29275/profile/fd8a6309071b165f6fb1d30b3747cf4eb6137177.jpg", + checkbox = false, + online = false, + locked = true, + scam = true, + verified = false, + muted = false, + time = "06:43", + checked = false, + read = false, + mentioned = false, + pinned = false, + counter = 0 + ), + PersonChat( + id = 10, + personName = "Котёнок", + lastMessage = "Новые машины завезли...", + avatarUrl = "https://w7.pngwing.com/pngs/873/489/png-transparent-avatar-youtube-cat-cute-dog-heroes-cat-like-mammal-carnivoran.png", + checkbox = false, + online = false, + locked = false, + scam = false, + verified = true, + muted = false, + time = "15:01", + checked = true, + read = false, + mentioned = false, + pinned = false, + counter = 0 + ) +) \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_online.xml b/app/src/main/res/drawable/circle_online.xml new file mode 100644 index 0000000..6d3f693 --- /dev/null +++ b/app/src/main/res/drawable/circle_online.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fake_badge.xml b/app/src/main/res/drawable/fake_badge.xml new file mode 100644 index 0000000..8cf1c5c --- /dev/null +++ b/app/src/main/res/drawable/fake_badge.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/group_badge.xml b/app/src/main/res/drawable/group_badge.xml new file mode 100644 index 0000000..6fd00a0 --- /dev/null +++ b/app/src/main/res/drawable/group_badge.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_archive.xml b/app/src/main/res/drawable/icon_archive.xml new file mode 100644 index 0000000..0e9d0f4 --- /dev/null +++ b/app/src/main/res/drawable/icon_archive.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_checkbox.xml b/app/src/main/res/drawable/icon_checkbox.xml new file mode 100644 index 0000000..aabd6df --- /dev/null +++ b/app/src/main/res/drawable/icon_checkbox.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_checked.xml b/app/src/main/res/drawable/icon_checked.xml new file mode 100644 index 0000000..ed05956 --- /dev/null +++ b/app/src/main/res/drawable/icon_checked.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_locked.xml b/app/src/main/res/drawable/icon_locked.xml new file mode 100644 index 0000000..9162c2c --- /dev/null +++ b/app/src/main/res/drawable/icon_locked.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_mention.xml b/app/src/main/res/drawable/icon_mention.xml new file mode 100644 index 0000000..504eda9 --- /dev/null +++ b/app/src/main/res/drawable/icon_mention.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_mute.xml b/app/src/main/res/drawable/icon_mute.xml new file mode 100644 index 0000000..d797962 --- /dev/null +++ b/app/src/main/res/drawable/icon_mute.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_pencil.xml b/app/src/main/res/drawable/icon_pencil.xml new file mode 100644 index 0000000..b2c0854 --- /dev/null +++ b/app/src/main/res/drawable/icon_pencil.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_pinned.xml b/app/src/main/res/drawable/icon_pinned.xml new file mode 100644 index 0000000..e9b96b9 --- /dev/null +++ b/app/src/main/res/drawable/icon_pinned.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_read.xml b/app/src/main/res/drawable/icon_read.xml new file mode 100644 index 0000000..f8355ec --- /dev/null +++ b/app/src/main/res/drawable/icon_read.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_verified.xml b/app/src/main/res/drawable/icon_verified.xml new file mode 100644 index 0000000..b332cd8 --- /dev/null +++ b/app/src/main/res/drawable/icon_verified.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/menu_19x13.xml b/app/src/main/res/drawable/menu_19x13.xml new file mode 100644 index 0000000..0fb075b --- /dev/null +++ b/app/src/main/res/drawable/menu_19x13.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/person_badge.xml b/app/src/main/res/drawable/person_badge.xml new file mode 100644 index 0000000..72c670b --- /dev/null +++ b/app/src/main/res/drawable/person_badge.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/voip_badge.xml b/app/src/main/res/drawable/voip_badge.xml new file mode 100644 index 0000000..4fc404e --- /dev/null +++ b/app/src/main/res/drawable/voip_badge.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/roboto.ttf b/app/src/main/res/font/roboto.ttf new file mode 100644 index 0000000..440843a Binary files /dev/null and b/app/src/main/res/font/roboto.ttf differ diff --git a/app/src/main/res/font/roboto_medium.ttf b/app/src/main/res/font/roboto_medium.ttf new file mode 100644 index 0000000..3e87dbd Binary files /dev/null and b/app/src/main/res/font/roboto_medium.ttf differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2d026df..a51d7aa 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,13 +1,57 @@ - - + + + + + + + + + + + + + + + android:layout_height="match_parent"> + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/view_holder_group.xml b/app/src/main/res/layout/view_holder_group.xml new file mode 100644 index 0000000..f5bf23a --- /dev/null +++ b/app/src/main/res/layout/view_holder_group.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_holder_person.xml b/app/src/main/res/layout/view_holder_person.xml new file mode 100644 index 0000000..45e1356 --- /dev/null +++ b/app/src/main/res/layout/view_holder_person.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 114f376..ad4c034 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,23 @@ - \ 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 diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..873dfa7 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ 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..ddc0dc1 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,17 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF + #FFFFFFFF + #FF517DA2 + #FFD5E8F7 + #FF8D8E90 + #FF222222 + #FF434449 + #FF8D8E90 + #FF95999A + #FF3D95D4 + #FF868686 + #FFC5C9CC + #FFD9D9D9 + #FF32A8E6 + #FF51AEE7 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d78b1f..d2b11b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,26 @@ - RecyclerView + Telegram + All Chats + Work + Bots + Group chat avatar + Person chat avatar + group_name? + person_name? + last_author? + last_message? + time_value? + Voip badge + Verified icon + Mute icon + Pinned icon + Mention icon + Checked icon + Checkbox badge + Locked icon + Scam patch + Scam patch + Unknown view type + FAB description + Archive \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..59bf5e7 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2187cf1..ad4c034 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,16 +1,23 @@ - \ No newline at end of file