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
|
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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user