feat(chat): DM 채팅방 Activity를 추가한다

This commit is contained in:
2026-06-11 11:17:04 +09:00
parent f2687b8243
commit 590a52c605
2 changed files with 269 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm
import android.content.Context
import android.content.Intent
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityDmChatRoomBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
import kr.co.vividnext.sodalive.v2.main.chat.dm.ui.DmChatMessageAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
ActivityDmChatRoomBinding::inflate
) {
private val viewModel: DmChatRoomViewModel by viewModel()
private val messageAdapter = DmChatMessageAdapter { localId -> viewModel.retry(localId) }
private lateinit var layoutManager: LinearLayoutManager
private var roomId: Long = 0L
private var creatorId: Long = 0L
private var isStarted: Boolean = false
private var firstVisiblePositionBeforePrepend: Int = RecyclerView.NO_POSITION
private var firstVisibleTopBeforePrepend: Int = 0
override fun setupView() {
roomId = intent.getLongExtra(EXTRA_ROOM_ID, 0L)
creatorId = intent.getLongExtra(EXTRA_CREATOR_ID, 0L)
setupHeader()
setupRecyclerView()
setupInput()
bindViewModel()
viewModel.enter(roomId = roomId, creatorId = creatorId)
}
override fun onStart() {
super.onStart()
isStarted = true
viewModel.connectRealtime()
}
override fun onStop() {
isStarted = false
viewModel.disconnectRealtime()
super.onStop()
}
private fun setupHeader() {
binding.ivBack.setOnClickListener { finish() }
binding.ivProfile.setImageResource(R.drawable.ic_placeholder_profile)
binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile)
}
private fun setupRecyclerView() {
layoutManager = LinearLayoutManager(this).apply {
stackFromEnd = true
}
binding.rvMessages.layoutManager = layoutManager
binding.rvMessages.adapter = messageAdapter
(binding.rvMessages.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
binding.rvMessages.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (layoutManager.findFirstVisibleItemPosition() == 0) {
rememberScrollBeforePrepend()
viewModel.loadOlderMessages()
}
}
})
}
private fun setupInput() {
setSendButtonEnabled(false)
binding.etMessage.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
setSendButtonEnabled(!s.isNullOrBlank())
}
override fun afterTextChanged(s: Editable?) = Unit
})
binding.etMessage.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) {
if (binding.ivSend.isEnabled) binding.ivSend.performClick()
true
} else {
false
}
}
binding.ivSend.setOnClickListener { onSendClicked() }
}
private fun bindViewModel() {
viewModel.chatRoomStateLiveData.observe(this) { state ->
when (state) {
is DmChatRoomUiState.Content -> bindContent(state)
is DmChatRoomUiState.Error -> showToast(state.message.orEmpty())
DmChatRoomUiState.Loading -> Unit
}
}
viewModel.finishEventLiveData.observe(this) {
if (it == true) finish()
}
viewModel.prependedMessageCountLiveData.observe(this) { count ->
if (count > 0) restoreScrollAfterPrepend(count)
}
viewModel.roomOpenedEventLiveData.observe(this) {
if (it.consume() == true) connectRealtimeIfStarted()
}
}
private fun bindContent(state: DmChatRoomUiState.Content) {
val shouldScrollToBottom = isNearBottom()
binding.tvName.text = state.opponentNickname
binding.ivProfile.loadUrl(state.opponentProfileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
binding.ivBackgroundProfile.loadUrl(state.opponentProfileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
}
messageAdapter.submitItems(state.messages)
if (shouldScrollToBottom && state.messages.isNotEmpty()) {
binding.rvMessages.scrollToPosition(state.messages.lastIndex)
}
}
private fun onSendClicked() {
val text = binding.etMessage.text?.toString().orEmpty()
if (text.isBlank()) return
viewModel.sendText(text)
binding.etMessage.setText("")
setSendButtonEnabled(false)
hideKeyboard(binding.etMessage)
}
private fun setSendButtonEnabled(enabled: Boolean) {
binding.ivSend.isEnabled = enabled
binding.ivSend.alpha = if (enabled) 1.0f else 0.4f
}
private fun rememberScrollBeforePrepend() {
firstVisiblePositionBeforePrepend = layoutManager.findFirstVisibleItemPosition()
val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePositionBeforePrepend)
firstVisibleTopBeforePrepend = firstVisibleView?.top ?: 0
}
private fun restoreScrollAfterPrepend(count: Int) {
if (firstVisiblePositionBeforePrepend == RecyclerView.NO_POSITION) return
layoutManager.scrollToPositionWithOffset(
firstVisiblePositionBeforePrepend + count,
firstVisibleTopBeforePrepend
)
firstVisiblePositionBeforePrepend = RecyclerView.NO_POSITION
firstVisibleTopBeforePrepend = 0
}
private fun isNearBottom(): Boolean {
val itemCount = messageAdapter.itemCount
if (itemCount == 0) return true
val lastVisible = layoutManager.findLastVisibleItemPosition()
return lastVisible >= itemCount - NEAR_BOTTOM_THRESHOLD
}
private fun connectRealtimeIfStarted() {
if (!isStarted) return
viewModel.connectRealtime()
}
private fun hideKeyboard(view: View) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
}
companion object {
const val EXTRA_ROOM_ID: String = "extra_room_id"
const val EXTRA_CREATOR_ID: String = "extra_creator_id"
private const val NEAR_BOTTOM_THRESHOLD = 2
fun newIntentByRoomId(context: Context, roomId: Long): Intent {
return Intent(context, DmChatRoomActivity::class.java).apply {
putExtra(EXTRA_ROOM_ID, roomId)
}
}
fun newIntentByCreatorId(context: Context, creatorId: Long): Intent {
return Intent(context, DmChatRoomActivity::class.java).apply {
putExtra(EXTRA_CREATOR_ID, creatorId)
}
}
}
}

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class DmChatRoomActivitySourceTest {
@Test
fun `DM 채팅방 Activity source는 intent binding input pagination scroll lifecycle을 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt"
).readText()
assertTrue(source.contains("BaseActivity<ActivityDmChatRoomBinding>"))
assertTrue(source.contains("const val EXTRA_ROOM_ID"))
assertTrue(source.contains("const val EXTRA_CREATOR_ID"))
assertTrue(source.contains("fun newIntentByRoomId"))
assertTrue(source.contains("fun newIntentByCreatorId"))
assertTrue(source.contains("private val viewModel: DmChatRoomViewModel by viewModel()"))
assertTrue(source.contains("viewModel.enter(roomId = roomId, creatorId = creatorId)"))
assertTrue(source.contains("binding.ivBack.setOnClickListener { finish() }"))
assertTrue(source.contains("DmChatMessageAdapter"))
assertTrue(source.contains("binding.etMessage.setOnEditorActionListener"))
assertTrue(source.contains("EditorInfo.IME_ACTION_SEND"))
assertTrue(source.contains("binding.ivSend.setOnClickListener"))
assertTrue(source.contains("viewModel.sendText"))
assertTrue(source.contains("findFirstVisibleItemPosition()"))
assertTrue(source.contains("viewModel.loadOlderMessages()"))
assertTrue(source.contains("prependedMessageCountLiveData.observe(this)"))
assertTrue(source.contains("scrollToPositionWithOffset"))
assertTrue(source.contains("isNearBottom()"))
assertTrue(source.contains("private var isStarted: Boolean = false"))
assertTrue(source.contains("viewModel.connectRealtime()"))
assertTrue(source.contains("roomOpenedEventLiveData.observe(this)"))
assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()"))
assertTrue(source.contains("connectRealtimeIfStarted()"))
assertTrue(source.contains("viewModel.disconnectRealtime()"))
assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()"))
assertTrue(!source.contains("character_type_badge"))
assertTrue(!source.contains("notice_container"))
}
@Test
fun `DM 채팅방 Activity intent helper는 roomId와 creatorId를 각각 전달한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt"
).readText()
assertTrue(source.contains("Intent(context, DmChatRoomActivity::class.java)"))
assertTrue(source.contains("putExtra(EXTRA_ROOM_ID, roomId)"))
assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}