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:
2025-08-13 21:37:42 +09:00
parent 45b76da1e8
commit 0cf0d2e790

View File

@@ -1,19 +1,233 @@
package kr.co.vividnext.sodalive.chat.talk.room package kr.co.vividnext.sodalive.chat.talk.room
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent 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.base.BaseActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterType
import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding
import androidx.core.content.edit
class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>( class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
ActivityChatRoomBinding::inflate ActivityChatRoomBinding::inflate
) { ) {
override fun setupView() { private var roomId: Long = 0L
// TODO: roomId를 활용한 채팅방 초기화 로직 추가 예정 private lateinit var chatAdapter: ChatMessageAdapter
val roomId = intent.getLongExtra(EXTRA_ROOM_ID, 0L) private lateinit var layoutManager: LinearLayoutManager
// 필요 시 roomId 유효성 체크 및 UI 바인딩
// 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 { companion object {
const val EXTRA_ROOM_ID: String = "extra_room_id" const val EXTRA_ROOM_ID: String = "extra_room_id"