feat(chat-room-ui): ChatMessageAdapter 구현
This commit is contained in:
		@@ -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<RecyclerView.ViewHolder>() {
 | 
			
		||||
 | 
			
		||||
    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<ChatListItem> = 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<ChatListItem>) {
 | 
			
		||||
        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
 | 
			
		||||
		Reference in New Issue
	
	Block a user