From 0cf0d2e790ceee495f596a3ceb00c43ed7c8df6a Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 13 Aug 2025 21:37:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-room-ui):=205.1~5.5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20Activity=20=EA=B5=AC=EC=A1=B0/=ED=97=A4?= =?UTF-8?q?=EB=8D=94/=EC=95=88=EB=82=B4/=EB=B0=B0=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왜: 채팅방 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) 작성 및 정리 --- .../chat/talk/room/ChatRoomActivity.kt | 222 +++++++++++++++++- 1 file changed, 218 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt index 01fad949..4ca90d9d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt @@ -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::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 = 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) { + 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"