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"