From 45b76da1e81f76b887d12046db9812fca308df3f Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 13 Aug 2025 21:08:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-room-ui):=20ChatMessageAdapter=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/talk/room/ChatMessageAdapter.kt | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt new file mode 100644 index 00000000..f1e37947 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt @@ -0,0 +1,390 @@ +/* + * 보이스온 - 채팅방 메시지 어댑터 (4.2 ViewHolder 구현) + * - 4가지 ViewType 지원 + * - ViewHolder 바인딩 로직 구현: 시간 포맷팅, 상태 표시, 이미지 로딩, 그룹화 처리, 타이핑 애니메이션 + */ +package kr.co.vividnext.sodalive.chat.talk.room + +import android.annotation.SuppressLint +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.TextView +import androidx.annotation.LayoutRes +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import coil.load +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding +import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding +import kr.co.vividnext.sodalive.databinding.ItemChatUserMessageBinding +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 채팅 리스트 아이템 타입 정의 (UI 렌더링을 위한 래퍼) + */ +sealed class ChatListItem { + data class UserMessage(val data: ChatMessage) : ChatListItem() + data class AiMessage(val data: ChatMessage, val displayName: String? = null) : ChatListItem() + data class Notice(val text: String) : ChatListItem() + object TypingIndicator : ChatListItem() +} + +class ChatMessageAdapter : RecyclerView.Adapter() { + + companion object { + const val VIEW_TYPE_USER_MESSAGE = 1 + const val VIEW_TYPE_AI_MESSAGE = 2 + const val VIEW_TYPE_NOTICE = 3 + const val VIEW_TYPE_TYPING_INDICATOR = 4 + } + + private val items: MutableList = mutableListOf() + + // Notice 접기 상태 (간단 전역 토글) + private var isNoticeCollapsed: Boolean = false + + // 타이핑 인디케이터 표시 상태 + private var isTypingVisible: Boolean = false + + private fun findTypingIndicatorIndex(): Int { + return items.indexOfLast { it is ChatListItem.TypingIndicator } + } + + /** + * 타이핑 인디케이터를 목록 하단에 표시한다. + * 이미 표시 중이면 중복 삽입하지 않는다. + */ + fun showTypingIndicator() { + if (isTypingVisible) return + // 목록의 마지막에 추가 + items.add(ChatListItem.TypingIndicator) + isTypingVisible = true + notifyItemInserted(items.lastIndex) + } + + /** + * 타이핑 인디케이터를 숨긴다(제거). + */ + fun hideTypingIndicator() { + val index = findTypingIndicatorIndex() + if (index >= 0) { + items.removeAt(index) + isTypingVisible = false + notifyItemRemoved(index) + } + } + + @SuppressLint("NotifyDataSetChanged") + fun setItems(newItems: List) { + items.clear() + items.addAll(newItems) + notifyDataSetChanged() + } + + fun addItem(item: ChatListItem) { + items.add(item) + notifyItemInserted(items.lastIndex) + } + + override fun getItemCount(): Int = items.size + + override fun getItemViewType(position: Int): Int { + return when (items[position]) { + is ChatListItem.UserMessage -> VIEW_TYPE_USER_MESSAGE + is ChatListItem.AiMessage -> VIEW_TYPE_AI_MESSAGE + is ChatListItem.Notice -> VIEW_TYPE_NOTICE + is ChatListItem.TypingIndicator -> VIEW_TYPE_TYPING_INDICATOR + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_USER_MESSAGE -> + UserMessageViewHolder( + ItemChatUserMessageBinding.inflate( + inflater, + parent, + false + ) + ) + + VIEW_TYPE_AI_MESSAGE -> + AiMessageViewHolder( + ItemChatAiMessageBinding.inflate( + inflater, + parent, + false + ) + ) + + VIEW_TYPE_TYPING_INDICATOR -> + TypingIndicatorViewHolder( + ItemChatTypingIndicatorBinding.inflate( + inflater, + parent, + false + ) + ) + + VIEW_TYPE_NOTICE -> { + // 전용 레이아웃이 아직 없으므로 기본 텍스트 아이템으로 구현하고 접기 기능 지원 + NoticeMessageViewHolder(inflateAndroidSimpleItem(parent)) + } + + else -> throw IllegalArgumentException("Unknown viewType: $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + // 그룹화 판정: 같은 발신자 연속 여부와 그룹의 마지막 여부 계산 + fun isSameSender(prev: ChatListItem?, curr: ChatListItem?): Boolean { + if (prev == null || curr == null) return false + val p = when (prev) { + is ChatListItem.UserMessage -> prev.data.mine + is ChatListItem.AiMessage -> prev.data.mine + else -> return false + } + val c = when (curr) { + is ChatListItem.UserMessage -> curr.data.mine + is ChatListItem.AiMessage -> curr.data.mine + else -> return false + } + return p == c + } + + val currItem = items[position] + val prevItem = if (position > 0) items[position - 1] else null + val nextItem = if (position < items.lastIndex) items[position + 1] else null + val grouped = isSameSender(prevItem, currItem) + val isLastInGroup = !isSameSender(currItem, nextItem) + + when (holder) { + is UserMessageViewHolder -> { + val item = currItem as ChatListItem.UserMessage + holder.bind(item.data, showTime = isLastInGroup, isGrouped = grouped) + } + + is AiMessageViewHolder -> { + val item = currItem as ChatListItem.AiMessage + holder.bind( + item.data, + item.displayName, + isGrouped = grouped, + showTime = isLastInGroup + ) + } + + is NoticeMessageViewHolder -> { + val item = currItem as ChatListItem.Notice + holder.bind(item.text, isNoticeCollapsed) { + isNoticeCollapsed = !isNoticeCollapsed + notifyItemChanged(position) + } + } + + is TypingIndicatorViewHolder -> { + holder.bind() + } + } + } + + // region ViewHolders + + /** 사용자 메시지 뷰홀더: 시간 포맷팅, 상태(투명도) 표시 */ + class UserMessageViewHolder( + private val binding: ItemChatUserMessageBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(data: ChatMessage, showTime: Boolean, isGrouped: Boolean) { + binding.tvMessage.text = data.message + binding.tvTime.text = formatMessageTime(data.createdAt) + binding.tvTime.visibility = if (showTime) View.VISIBLE else View.INVISIBLE + + // 상태에 따른 시각적 피드백: 전송중은 약간 투명, 실패는 더 투명 + val alpha = when (data.status) { + MessageStatus.SENDING -> 0.6f + MessageStatus.FAILED -> 0.4f + MessageStatus.SENT -> 1.0f + } + binding.messageContainer.alpha = alpha + + // 그룹 내부 간격 최소화 (상단 패딩 축소) + adjustTopPadding(isGrouped) + + // 접근성: 상태 포함 설명 + val statusDesc = when (data.status) { + MessageStatus.SENDING -> "전송 중" + MessageStatus.FAILED -> "전송 실패" + MessageStatus.SENT -> "전송 완료" + } + + val timeDesc = if (showTime) { + binding.tvTime.text + } else { + "" + } + + binding.root.contentDescription = + "내 메시지 ${binding.tvMessage.text}, $statusDesc, $timeDesc" + } + + private fun adjustTopPadding(isGrouped: Boolean) { + val density = binding.root.resources.displayMetrics.density + val top = if (isGrouped) (2 * density).toInt() else (6 * density).toInt() + val bottom = (6 * density).toInt() + val start = binding.root.paddingStart + val end = binding.root.paddingEnd + binding.root.setPaddingRelative(start, top, end, bottom) + } + } + + /** AI 메시지 뷰홀더: 이미지 로딩, 그룹화 처리, 시간 포맷팅 */ + class AiMessageViewHolder( + private val binding: ItemChatAiMessageBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(data: ChatMessage, displayName: String?, isGrouped: Boolean, showTime: Boolean) { + binding.tvMessage.text = data.message + binding.tvTime.text = formatMessageTime(data.createdAt) + binding.tvTime.visibility = if (showTime) View.VISIBLE else View.INVISIBLE + + // 그룹화: isGrouped가 true면 프로필/이름 숨김 + binding.ivProfile.visibility = if (isGrouped) View.INVISIBLE else View.VISIBLE + binding.tvName.visibility = if (isGrouped) View.GONE else View.VISIBLE + if (!isGrouped) { + binding.tvName.text = displayName ?: "" + } + + // 프로필 이미지 로딩 (Coil) + if (binding.ivProfile.isVisible) { + binding.ivProfile.load(data.profileImageUrl) { + placeholder(R.drawable.ic_placeholder_profile) + error(R.drawable.ic_placeholder_profile) + } + } + + // 그룹 내부 간격 최소화 (상단 패딩 축소) + adjustTopPadding(isGrouped) + + // 접근성 + itemView.contentDescription = buildString { + if (!isGrouped && !binding.tvName.text.isNullOrEmpty()) { + append(binding.tvName.text) + append(" ") + } + append("메시지 ") + append(binding.tvMessage.text) + if (showTime) { + append(", ") + append(binding.tvTime.text) + } + } + } + + private fun adjustTopPadding(isGrouped: Boolean) { + val density = itemView.resources.displayMetrics.density + val top = if (isGrouped) (2 * density).toInt() else (6 * density).toInt() + val bottom = (6 * density).toInt() + val start = itemView.paddingStart + val end = itemView.paddingEnd + itemView.setPaddingRelative(start, top, end, bottom) + } + } + + /** 안내 메시지 뷰홀더: 텍스트 표시 + 간단한 접기/펼치기 */ + class NoticeMessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val textView: TextView = itemView.findViewById(android.R.id.text1) + fun bind(text: String, collapsed: Boolean, onToggle: () -> Unit) { + textView.text = text + // 접힘 상태: 한 줄 + 말줄임표, 펼침 상태: 여러 줄 + if (collapsed) { + textView.maxLines = 1 + textView.ellipsize = TextUtils.TruncateAt.END + } else { + textView.maxLines = 10_000 + textView.ellipsize = null + } + itemView.setOnClickListener { onToggle() } + } + } + + /** 타이핑 인디케이터 뷰홀더: 점 애니메이션 시작/정지 */ + class TypingIndicatorViewHolder( + private val binding: ItemChatTypingIndicatorBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind() { + startTypingAnimation() + } + + private fun startTypingAnimation() { + val context = binding.root.context + val anim1: Animation = AnimationUtils.loadAnimation( + context, + R.anim.typing_dots_animation + ) + + val anim2: Animation = AnimationUtils.loadAnimation( + context, + R.anim.typing_dots_animation + ).apply { startOffset = 200 } + + val anim3: Animation = AnimationUtils.loadAnimation( + context, + R.anim.typing_dots_animation + ).apply { startOffset = 400 } + + binding.dot1.startAnimation(anim1) + binding.dot2.startAnimation(anim2) + binding.dot3.startAnimation(anim3) + } + + fun stopTypingAnimation() { + binding.dot1.clearAnimation() + binding.dot2.clearAnimation() + binding.dot3.clearAnimation() + } + } + + // endregion + + // region Util + private fun inflateAndroidSimpleItem( + parent: ViewGroup, + @LayoutRes layout: Int = android.R.layout.simple_list_item_1 + ): View { + return LayoutInflater.from(parent.context) + .inflate(layout, parent, false) + } + // endregion + + override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { + super.onViewAttachedToWindow(holder) + if (holder is TypingIndicatorViewHolder) { + holder.bind() + } + } + + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + if (holder is TypingIndicatorViewHolder) { + holder.stopTypingAnimation() + } + super.onViewDetachedFromWindow(holder) + } +} + +// region file-level util +private fun formatMessageTime(timestamp: Long): String { + return try { + val df = SimpleDateFormat("a h:mm", Locale.getDefault()) + df.format(Date(timestamp)) + } catch (_: Exception) { + "" + } +} +// endregion