diff --git a/app/build.gradle b/app/build.gradle index 54e4eac..e69e845 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/otus/gpb/recyclerview/Chat.kt b/app/src/main/java/otus/gpb/recyclerview/Chat.kt new file mode 100644 index 0000000..f7610ed --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/Chat.kt @@ -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 } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatAdapter.kt b/app/src/main/java/otus/gpb/recyclerview/ChatAdapter.kt new file mode 100644 index 0000000..6194898 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatAdapter.kt @@ -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(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(R.id.avatarImage) + private val avatarInitials = itemView.findViewById(R.id.avatarInitials) + private val online = itemView.findViewById(R.id.onlineIndicator) + private val title = itemView.findViewById(R.id.chatTitle) + private val verified = itemView.findViewById(R.id.verifiedBadge) + private val muted = itemView.findViewById(R.id.mutedBadge) + private val message = itemView.findViewById(R.id.lastMessage) + private val typingDots = itemView.findViewById(R.id.typingDots) + private val time = itemView.findViewById(R.id.chatTime) + private val delivery = itemView.findViewById(R.id.deliveryState) + private val firstCheck = itemView.findViewById(R.id.firstCheck) + private val unread = itemView.findViewById(R.id.unreadBadge) + private val pinned = itemView.findViewById(R.id.pinnedBadge) + private val mention = itemView.findViewById(R.id.mentionBadge) + private val trailingStatus = itemView.findViewById(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() { + override fun areItemsTheSame(oldItem: Chat, newItem: Chat) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: Chat, newItem: Chat) = oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatDividerDecoration.kt b/app/src/main/java/otus/gpb/recyclerview/ChatDividerDecoration.kt new file mode 100644 index 0000000..18bfd7d --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatDividerDecoration.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatFactory.kt b/app/src/main/java/otus/gpb/recyclerview/ChatFactory.kt new file mode 100644 index 0000000..1ff22cc --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatFactory.kt @@ -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 = 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatSwipeCallback.kt b/app/src/main/java/otus/gpb/recyclerview/ChatSwipeCallback.kt new file mode 100644 index 0000000..d3e7992 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatSwipeCallback.kt @@ -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) + } +} \ 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..102e686 100644 --- a/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt +++ b/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt @@ -1,12 +1,94 @@ package otus.gpb.recyclerview -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView class MainActivity : AppCompatActivity() { + private lateinit var adapter: ChatAdapter + private var isLoading = false + private var nextPage = 0 + private val deletedChatIds = mutableSetOf() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + nextPage = savedInstanceState?.getInt(STATE_NEXT_PAGE) ?: 0 + savedInstanceState?.getLongArray(STATE_DELETED_IDS)?.forEach(deletedChatIds::add) + + adapter = ChatAdapter() + val recyclerView = findViewById(R.id.recyclerView) + val layoutManager = LinearLayoutManager(this) + + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter + recyclerView.addItemDecoration( + ChatDividerDecoration( + dividerColor = ContextCompat.getColor(this, R.color.divider), + startInset = resources.getDimensionPixelSize(R.dimen.chat_divider_inset), + height = resources.getDimensionPixelSize(R.dimen.chat_divider_height), + ), + ) + + val swipeCallback = ChatSwipeCallback(this) { chatId -> + deletedChatIds += chatId + adapter.submitList(adapter.currentList.filterNot { it.id == chatId }) + } + ItemTouchHelper(swipeCallback).attachToRecyclerView(recyclerView) + + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy <= 0 || isLoading) return + val lastVisible = layoutManager.findLastVisibleItemPosition() + if (lastVisible >= adapter.itemCount - PAGINATION_THRESHOLD) { + loadNextPage() + } + } + }) + + if (nextPage == 0) { + loadNextPage { ensureViewportFilled(recyclerView) } + } else { + adapter.submitList( + (0 until nextPage) + .flatMap { ChatFactory.createPage(it) } + .filterNot { it.id in deletedChatIds }, + ) { ensureViewportFilled(recyclerView) } + } + } + + private fun loadNextPage(onCommitted: (() -> Unit)? = null) { + if (isLoading) return + isLoading = true + val newItems = ChatFactory.createPage(nextPage++).filterNot { it.id in deletedChatIds } + adapter.submitList(adapter.currentList + newItems) { + isLoading = false + onCommitted?.invoke() + } + } + + private fun ensureViewportFilled(recyclerView: RecyclerView) { + recyclerView.post { + if (!recyclerView.canScrollVertically(1) && adapter.itemCount > 0) { + loadNextPage { ensureViewportFilled(recyclerView) } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(STATE_NEXT_PAGE, nextPage) + outState.putLongArray(STATE_DELETED_IDS, deletedChatIds.toLongArray()) + super.onSaveInstanceState(outState) + } + + private companion object { + const val PAGINATION_THRESHOLD = 4 + const val STATE_NEXT_PAGE = "next_page" + const val STATE_DELETED_IDS = "deleted_chat_ids" } } \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/avatar_catbird.png b/app/src/main/res/drawable-nodpi/avatar_catbird.png new file mode 100644 index 0000000..d13286e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_catbird.png differ diff --git a/app/src/main/res/drawable-nodpi/avatar_dima.png b/app/src/main/res/drawable-nodpi/avatar_dima.png new file mode 100644 index 0000000..082ed16 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_dima.png differ diff --git a/app/src/main/res/drawable-nodpi/avatar_just_design.png b/app/src/main/res/drawable-nodpi/avatar_just_design.png new file mode 100644 index 0000000..79406b8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_just_design.png differ diff --git a/app/src/main/res/drawable-nodpi/avatar_offtopic.png b/app/src/main/res/drawable-nodpi/avatar_offtopic.png new file mode 100644 index 0000000..a95ed84 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_offtopic.png differ diff --git a/app/src/main/res/drawable-nodpi/avatar_r4inbow.png b/app/src/main/res/drawable-nodpi/avatar_r4inbow.png new file mode 100644 index 0000000..5816b11 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_r4inbow.png differ diff --git a/app/src/main/res/drawable-nodpi/avatar_smddn.png b/app/src/main/res/drawable-nodpi/avatar_smddn.png new file mode 100644 index 0000000..b3aa6d6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_smddn.png differ diff --git a/app/src/main/res/drawable-nodpi/avatar_yes_no.png b/app/src/main/res/drawable-nodpi/avatar_yes_no.png new file mode 100644 index 0000000..5075023 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/avatar_yes_no.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_archive.png b/app/src/main/res/drawable-nodpi/ic_archive.png new file mode 100644 index 0000000..ba2aa1a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_archive.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_check.png b/app/src/main/res/drawable-nodpi/ic_check.png new file mode 100644 index 0000000..c742298 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_check.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_mention.png b/app/src/main/res/drawable-nodpi/ic_mention.png new file mode 100644 index 0000000..b667c0a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_mention.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_mute.png b/app/src/main/res/drawable-nodpi/ic_mute.png new file mode 100644 index 0000000..5fa5709 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_mute.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_pinned.png b/app/src/main/res/drawable-nodpi/ic_pinned.png new file mode 100644 index 0000000..ab2905b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_pinned.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_typing_dots.png b/app/src/main/res/drawable-nodpi/ic_typing_dots.png new file mode 100644 index 0000000..078e5b8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_typing_dots.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_verified.png b/app/src/main/res/drawable-nodpi/ic_verified.png new file mode 100644 index 0000000..e6af6a1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_verified.png differ diff --git a/app/src/main/res/drawable/bg_mention.xml b/app/src/main/res/drawable/bg_mention.xml new file mode 100644 index 0000000..f5ff4c9 --- /dev/null +++ b/app/src/main/res/drawable/bg_mention.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/bg_online.xml b/app/src/main/res/drawable/bg_online.xml new file mode 100644 index 0000000..6f84852 --- /dev/null +++ b/app/src/main/res/drawable/bg_online.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/bg_unread.xml b/app/src/main/res/drawable/bg_unread.xml new file mode 100644 index 0000000..22f539a --- /dev/null +++ b/app/src/main/res/drawable/bg_unread.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..47688c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..6d601d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2d026df..dbcb80a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,13 +1,52 @@ + + + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@color/white" + android:clipToPadding="false" + android:paddingBottom="88dp" + android:scrollbars="vertical" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/toolbar" + tools:listitem="@layout/item_chat" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_chat.xml b/app/src/main/res/layout/item_chat.xml new file mode 100644 index 0000000..3f2d6b0 --- /dev/null +++ b/app/src/main/res/layout/item_chat.xml @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..761adab 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,16 @@ - \ 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..b2f583d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,14 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 + #517DA2 + #51AEE7 + #70AAEA + #66A9E0 + #38A83B + #E84A4A + #222222 + #8D9295 + #C5C9CC + #E5E5E5 #FFFFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..b95c7f4 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + 79dp + 0.5dp + 15sp + \ 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..e1f3ae5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ RecyclerView + Telegram + Архив + %1$d \ 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..d3a790c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,16 +1,39 @@ - + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/otus/gpb/recyclerview/ChatFactoryTest.kt b/app/src/test/java/otus/gpb/recyclerview/ChatFactoryTest.kt new file mode 100644 index 0000000..d162cb1 --- /dev/null +++ b/app/src/test/java/otus/gpb/recyclerview/ChatFactoryTest.kt @@ -0,0 +1,37 @@ +package otus.gpb.recyclerview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatFactoryTest { + + @Test + fun `pages contain stable non-overlapping ids`() { + val firstPageIds = ChatFactory.createPage(page = 0).map(Chat::id) + val secondPageIds = ChatFactory.createPage(page = 1).map(Chat::id) + + assertEquals((0L..9L).toList(), firstPageIds) + assertTrue(firstPageIds.intersect(secondPageIds.toSet()).isEmpty()) + } + + @Test + fun `time correctly crosses hour boundary`() { + val chatAtNoon = ChatFactory.createPage(page = 2)[2] + + assertEquals(22L, chatAtNoon.id) + assertEquals("12:00", chatAtNoon.time) + } + + @Test + fun `factory exposes all special presentation states`() { + val chats = ChatFactory.createPage(page = 0) + + assertTrue(chats.any(Chat::isVerified)) + assertTrue(chats.any(Chat::isMuted)) + assertTrue(chats.any(Chat::isPinned)) + assertTrue(chats.any(Chat::hasMention)) + assertTrue(chats.any { it.messageState == Chat.MessageState.TYPING }) + assertTrue(chats.any { it.deliveryState == Chat.DeliveryState.READ }) + } +}