feat(chat-room-ui): 5.1~5.5 구현 - Activity 구조/헤더/안내/배경 및 스크롤
왜: 채팅방 UI tasks 5를 완료하여 기본 화면 구성을 완성하고 사용자 경험을 개선하기 위함 무엇: \n- 5.1 기본 Activity 구조 구현 (roomId 처리, setupView 골격)\n- 5.2 RecyclerView 설정 및 무한 스크롤/자동 스크롤/상단 prepend 보정 로직\n- 5.3 헤더 영역: 뒤로가기, 프로필(CoIL), 이름, 타입 배지(기존 배경 리소스)\n- 5.4 안내 메시지: SharedPreferences로 접기 상태 저장, 캐릭터 타입별 안내, strings 리소스 사용\n- 5.5 배경 프로필 이미지 로딩 및 딤 처리 적용(레이아웃 구성 활용) 추가: 관련 문서 docs/ (5.1/5.2/5.3/5.4/5.5, notice strings) 작성 및 정리
This commit is contained in:
		@@ -1,19 +1,233 @@
 | 
			
		||||
package kr.co.vividnext.sodalive.chat.talk.room
 | 
			
		||||
 | 
			
		||||
import android.annotation.SuppressLint
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import android.content.Intent
 | 
			
		||||
import androidx.core.view.isVisible
 | 
			
		||||
import androidx.recyclerview.widget.LinearLayoutManager
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import coil.load
 | 
			
		||||
import kr.co.vividnext.sodalive.R
 | 
			
		||||
import kr.co.vividnext.sodalive.base.BaseActivity
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.character.detail.CharacterType
 | 
			
		||||
import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding
 | 
			
		||||
import androidx.core.content.edit
 | 
			
		||||
 | 
			
		||||
class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
    ActivityChatRoomBinding::inflate
 | 
			
		||||
) {
 | 
			
		||||
    override fun setupView() {
 | 
			
		||||
        // TODO: roomId를 활용한 채팅방 초기화 로직 추가 예정
 | 
			
		||||
        val roomId = intent.getLongExtra(EXTRA_ROOM_ID, 0L)
 | 
			
		||||
        // 필요 시 roomId 유효성 체크 및 UI 바인딩
 | 
			
		||||
    private var roomId: Long = 0L
 | 
			
		||||
    private lateinit var chatAdapter: ChatMessageAdapter
 | 
			
		||||
    private lateinit var layoutManager: LinearLayoutManager
 | 
			
		||||
 | 
			
		||||
    // 5.2 무한 스크롤/자동 스크롤 상태
 | 
			
		||||
    private val items: MutableList<ChatListItem> = mutableListOf()
 | 
			
		||||
    private var isLoading: Boolean = false
 | 
			
		||||
    private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정
 | 
			
		||||
    private var nextCursor: Long? = null // 가장 오래된 메시지의 timestamp 등
 | 
			
		||||
 | 
			
		||||
    // 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관)
 | 
			
		||||
    private var characterInfo: CharacterInfo? = null
 | 
			
		||||
 | 
			
		||||
    // 5.4 SharedPreferences (안내 메시지 접힘 상태 저장)
 | 
			
		||||
    private val prefs by lazy { getSharedPreferences("chat_room_prefs", Context.MODE_PRIVATE) }
 | 
			
		||||
    private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${'$'}roomId"
 | 
			
		||||
    private fun isNoticeHidden(): Boolean = prefs.getBoolean(noticePrefKey(roomId), false)
 | 
			
		||||
    private fun setNoticeHidden(hidden: Boolean) {
 | 
			
		||||
        prefs.edit { putBoolean(noticePrefKey(roomId), hidden) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun setupView() {
 | 
			
		||||
        // roomId 파라미터 처리
 | 
			
		||||
        roomId = intent.getLongExtra(EXTRA_ROOM_ID, 0L)
 | 
			
		||||
 | 
			
		||||
        if (roomId <= 0) {
 | 
			
		||||
            finish()
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 기본 UI 영역 초기화 (5.2 구현 포함)
 | 
			
		||||
        setupHeader()
 | 
			
		||||
        setupNoticeArea()
 | 
			
		||||
        setupRecyclerView()
 | 
			
		||||
        setupInputArea()
 | 
			
		||||
        loadInitialMessages()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @SuppressLint("SetTextI18n")
 | 
			
		||||
    private fun setupHeader() {
 | 
			
		||||
        // 뒤로가기 버튼 동작
 | 
			
		||||
        binding.ivBack.setOnClickListener { finish() }
 | 
			
		||||
 | 
			
		||||
        // 5.3: characterInfo가 있으면 헤더 바인딩, 없으면 기본 플레이스홀더 유지
 | 
			
		||||
        characterInfo?.let { bindHeader(it) } ?: run {
 | 
			
		||||
            // 기본값: 이름 숨김 또는 플레이스 홀더 표시
 | 
			
		||||
            binding.tvName.text = ""
 | 
			
		||||
            binding.ivProfile.setImageResource(R.drawable.ic_placeholder_profile)
 | 
			
		||||
            binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile)
 | 
			
		||||
            // 배지는 기본 Clone으로 둔다가 실제 값으로 갱신 (디자인 기본 배경도 clone)
 | 
			
		||||
            binding.tvCharacterTypeBadge.text = "Clone"
 | 
			
		||||
            binding.tvCharacterTypeBadge.setBackgroundResource(R.drawable.bg_character_status_clone)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 외부(향후 ViewModel)에서 characterInfo 수신 시 헤더를 바로 갱신 하기 위한 메서드 */
 | 
			
		||||
    fun setCharacterInfo(info: CharacterInfo) {
 | 
			
		||||
        characterInfo = info
 | 
			
		||||
        bindHeader(info)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 5.3: 헤더 바인딩 구현 (프로필, 이름, 타입 배지) */
 | 
			
		||||
    private fun bindHeader(info: CharacterInfo) {
 | 
			
		||||
        // 이름
 | 
			
		||||
        binding.tvName.text = info.name
 | 
			
		||||
        binding.tvName.isVisible = info.name.isNotBlank()
 | 
			
		||||
 | 
			
		||||
        // 프로필 이미지 (Coil)
 | 
			
		||||
        binding.ivProfile.load(info.profileImageUrl) {
 | 
			
		||||
            placeholder(R.drawable.ic_placeholder_profile)
 | 
			
		||||
            error(R.drawable.ic_placeholder_profile)
 | 
			
		||||
        }
 | 
			
		||||
        // 배경 프로필 이미지 (5.5)
 | 
			
		||||
        binding.ivBackgroundProfile.load(info.profileImageUrl) {
 | 
			
		||||
            placeholder(R.drawable.ic_placeholder_profile)
 | 
			
		||||
            error(R.drawable.ic_placeholder_profile)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 타입 배지 텍스트 및 배경
 | 
			
		||||
        val (badgeText, badgeBg) = when (info.characterType) {
 | 
			
		||||
            CharacterType.CLONE -> "Clone" to R.drawable.bg_character_status_clone
 | 
			
		||||
            CharacterType.CHARACTER -> "Character" to R.drawable.bg_character_status_character
 | 
			
		||||
        }
 | 
			
		||||
        binding.tvCharacterTypeBadge.text = badgeText
 | 
			
		||||
        binding.tvCharacterTypeBadge.setBackgroundResource(badgeBg)
 | 
			
		||||
 | 
			
		||||
        // 접근성
 | 
			
		||||
        binding.ivProfile.contentDescription = "$badgeText 프로필 이미지"
 | 
			
		||||
 | 
			
		||||
        // 5.4: 캐릭터 타입에 맞춰 안내 메시지 텍스트 갱신
 | 
			
		||||
        updateNoticeText()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun setupRecyclerView() {
 | 
			
		||||
        layoutManager = LinearLayoutManager(this).apply {
 | 
			
		||||
            stackFromEnd = true // 메시지가 아래에서부터 채워짐
 | 
			
		||||
        }
 | 
			
		||||
        binding.rvMessages.layoutManager = layoutManager
 | 
			
		||||
 | 
			
		||||
        chatAdapter = ChatMessageAdapter()
 | 
			
		||||
        binding.rvMessages.adapter = chatAdapter
 | 
			
		||||
 | 
			
		||||
        // 상단 도달 시 이전 메시지 로드
 | 
			
		||||
        binding.rvMessages.addOnScrollListener(object : RecyclerView.OnScrollListener() {
 | 
			
		||||
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
 | 
			
		||||
                super.onScrolled(recyclerView, dx, dy)
 | 
			
		||||
                val firstVisible = layoutManager.findFirstVisibleItemPosition()
 | 
			
		||||
                if (firstVisible == 0 && hasMoreMessages && !isLoading) {
 | 
			
		||||
                    loadMoreMessages()
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun setupInputArea() {
 | 
			
		||||
        // 6.x에서 전송 활성화/키보드 처리/입력 검증 구현 예정
 | 
			
		||||
        // 안전한 초기 상태: 전송 버튼 접근성 라벨만 지정
 | 
			
		||||
        binding.ivSend.contentDescription = "전송"
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // region 5.4 Notice Area
 | 
			
		||||
    @SuppressLint("SetTextI18n")
 | 
			
		||||
    private fun setupNoticeArea() {
 | 
			
		||||
        // 저장된 접힘 상태 복원
 | 
			
		||||
        val hidden = isNoticeHidden()
 | 
			
		||||
        binding.noticeContainer.isVisible = !hidden
 | 
			
		||||
 | 
			
		||||
        // 캐릭터 타입에 따른 안내 텍스트 설정
 | 
			
		||||
        updateNoticeText()
 | 
			
		||||
 | 
			
		||||
        // 접기 버튼 동작: 숨기고 상태 저장
 | 
			
		||||
        binding.ivCollapse.setOnClickListener {
 | 
			
		||||
            binding.noticeContainer.isVisible = false
 | 
			
		||||
            setNoticeHidden(true)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun updateNoticeText() {
 | 
			
		||||
        val type = characterInfo?.characterType
 | 
			
		||||
        val text = when (type) {
 | 
			
		||||
            CharacterType.CHARACTER -> getString(R.string.chat_notice_character)
 | 
			
		||||
            CharacterType.CLONE, null -> getString(R.string.chat_notice_clone)
 | 
			
		||||
        }
 | 
			
		||||
        binding.tvNoticeMessage.text = text
 | 
			
		||||
    }
 | 
			
		||||
    // endregion 5.4 Notice Area
 | 
			
		||||
 | 
			
		||||
    private fun loadInitialMessages() {
 | 
			
		||||
        // 7.x에서 Repository 연동 및 초기 로딩 구현 예정
 | 
			
		||||
        items.clear()
 | 
			
		||||
        chatAdapter.setItems(items)
 | 
			
		||||
        // 초기 진입 시 최신 메시지로 스크롤 (하단)
 | 
			
		||||
        scrollToBottom()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // region 5.2 helper methods
 | 
			
		||||
 | 
			
		||||
    /** 새로운 메시지를 하단에 추가 하고 필요 시 자동 스크롤 한다. */
 | 
			
		||||
    private fun appendMessage(item: ChatListItem) {
 | 
			
		||||
        items.add(item)
 | 
			
		||||
        chatAdapter.addItem(item)
 | 
			
		||||
        autoScrollIfAtBottom()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 리스트 최하단으로 스크롤 */
 | 
			
		||||
    private fun scrollToBottom() {
 | 
			
		||||
        val count = chatAdapter.itemCount
 | 
			
		||||
        if (count > 0) {
 | 
			
		||||
            binding.rvMessages.scrollToPosition(count - 1)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 사용자가 하단 근처에 있을 때만 자동 스크롤 */
 | 
			
		||||
    private fun autoScrollIfAtBottom() {
 | 
			
		||||
        val lastVisible = layoutManager.findLastVisibleItemPosition()
 | 
			
		||||
        val threshold = 2 // 마지막에서 2개 이내면 자동 스크롤
 | 
			
		||||
        val targetIndex = chatAdapter.itemCount - 1
 | 
			
		||||
        if (lastVisible >= targetIndex - threshold) {
 | 
			
		||||
            binding.rvMessages.post { scrollToBottom() }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 상단에 이전 메시지들을 추가하면서 스크롤 위치를 보정한다. */
 | 
			
		||||
    private fun prependMessages(newItems: List<ChatListItem>) {
 | 
			
		||||
        if (newItems.isEmpty()) return
 | 
			
		||||
        val firstVisiblePos = layoutManager.findFirstVisibleItemPosition()
 | 
			
		||||
        val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePos)
 | 
			
		||||
        val topOffset = firstVisibleView?.top ?: 0
 | 
			
		||||
 | 
			
		||||
        items.addAll(0, newItems)
 | 
			
		||||
        chatAdapter.setItems(items)
 | 
			
		||||
 | 
			
		||||
        // 새로 추가된 항목 수만큼 기준 위치를 이동하고 기존 오프셋 유지
 | 
			
		||||
        binding.rvMessages.post {
 | 
			
		||||
            layoutManager.scrollToPositionWithOffset(firstVisiblePos + newItems.size, topOffset)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 상단 도달 시 이전 메시지를 로드한다. Repository 연동은 추후(7.x) 예정. */
 | 
			
		||||
    private fun loadMoreMessages() {
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        // TODO: 7.x에서 Repository 연동하여 서버에서 가져오기. 여기서는 구조만 구현.
 | 
			
		||||
        // 예시: 가장 오래된 메시지 createdAt을 커서로 사용.
 | 
			
		||||
        // val cursor = (items.lastOrNull() as? ChatListItem.UserMessage)?.data?.createdAt
 | 
			
		||||
 | 
			
		||||
        // 현재 단계에서는 더 로드할 메시지가 없다고 가정하고 즉시 종료
 | 
			
		||||
        isLoading = false
 | 
			
		||||
        hasMoreMessages = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // endregion
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        const val EXTRA_ROOM_ID: String = "extra_room_id"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user