fix(dm): 채팅방 입력과 프로필 표시를 보정한다

This commit is contained in:
2026-07-11 22:03:16 +09:00
parent 30f7bd7556
commit d436efdc66
2 changed files with 102 additions and 11 deletions

View File

@@ -4,15 +4,15 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R 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.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityDmChatRoomBinding import kr.co.vividnext.sodalive.databinding.ActivityDmChatRoomBinding
import kr.co.vividnext.sodalive.extensions.loadUrl 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.model.DmChatRoomUiState
@@ -25,6 +25,7 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
private val viewModel: DmChatRoomViewModel by viewModel() private val viewModel: DmChatRoomViewModel by viewModel()
private val messageAdapter = DmChatMessageAdapter { localId -> viewModel.retry(localId) } private val messageAdapter = DmChatMessageAdapter { localId -> viewModel.retry(localId) }
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(this, layoutInflater) }
private lateinit var layoutManager: LinearLayoutManager private lateinit var layoutManager: LinearLayoutManager
private var roomId: Long = 0L private var roomId: Long = 0L
private var creatorId: Long = 0L private var creatorId: Long = 0L
@@ -119,6 +120,9 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
viewModel.roomOpenedEventLiveData.observe(this) { viewModel.roomOpenedEventLiveData.observe(this) {
if (it.consume() == true) connectRealtimeIfStarted() if (it.consume() == true) connectRealtimeIfStarted()
} }
viewModel.isRealtimeConnectingLiveData.observe(this) { isConnecting ->
updateRealtimeLoadingDialog(isConnecting)
}
} }
private fun bindContent(state: DmChatRoomUiState.Content) { private fun bindContent(state: DmChatRoomUiState.Content) {
@@ -129,7 +133,7 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
error(R.drawable.ic_placeholder_profile) error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation()) transformations(CircleCropTransformation())
} }
binding.ivBackgroundProfile.loadUrl(state.opponentProfileImageUrl) { binding.ivBackgroundProfile.loadUrl(SharedPreferenceManager.profileImage) {
placeholder(R.drawable.ic_placeholder_profile) placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile) error(R.drawable.ic_placeholder_profile)
} }
@@ -143,10 +147,10 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
val text = binding.etMessage.text?.toString().orEmpty() val text = binding.etMessage.text?.toString().orEmpty()
if (text.isBlank()) return if (text.isBlank()) return
viewModel.sendText(text) if (viewModel.sendText(text)) {
binding.etMessage.setText("") binding.etMessage.setText("")
setSendButtonEnabled(false) setSendButtonEnabled(false)
hideKeyboard(binding.etMessage) }
} }
private fun setSendButtonEnabled(enabled: Boolean) { private fun setSendButtonEnabled(enabled: Boolean) {
@@ -182,10 +186,12 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
viewModel.connectRealtime() viewModel.connectRealtime()
} }
private fun hideKeyboard(view: View) { private fun updateRealtimeLoadingDialog(isConnecting: Boolean) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager if (isConnecting) {
imm?.hideSoftInputFromWindow(view.windowToken, 0) loadingDialog.show(screenWidth)
view.clearFocus() } else {
loadingDialog.dismiss()
}
} }
companion object { companion object {

View File

@@ -14,6 +14,8 @@ class DmChatRoomActivitySourceTest {
).readText() ).readText()
assertTrue(source.contains("BaseActivity<ActivityDmChatRoomBinding>")) assertTrue(source.contains("BaseActivity<ActivityDmChatRoomBinding>"))
assertTrue(source.contains("import kr.co.vividnext.sodalive.common.LoadingDialog"))
assertTrue(source.contains("private val loadingDialog: LoadingDialog by lazy"))
assertTrue(source.contains("const val EXTRA_ROOM_ID")) assertTrue(source.contains("const val EXTRA_ROOM_ID"))
assertTrue(source.contains("const val EXTRA_CREATOR_ID")) assertTrue(source.contains("const val EXTRA_CREATOR_ID"))
assertTrue(source.contains("fun newIntentByRoomId")) assertTrue(source.contains("fun newIntentByRoomId"))
@@ -26,6 +28,11 @@ class DmChatRoomActivitySourceTest {
assertTrue(source.contains("EditorInfo.IME_ACTION_SEND")) assertTrue(source.contains("EditorInfo.IME_ACTION_SEND"))
assertTrue(source.contains("binding.ivSend.setOnClickListener")) assertTrue(source.contains("binding.ivSend.setOnClickListener"))
assertTrue(source.contains("viewModel.sendText")) assertTrue(source.contains("viewModel.sendText"))
assertTrue(source.contains("if (viewModel.sendText(text))"))
assertFalse(source.contains("viewModel.sendText(text)\n binding.etMessage.setText(\"\")"))
assertFalse(source.contains("hideKeyboard"))
assertFalse(source.contains("hideSoftInputFromWindow"))
assertFalse(source.contains("view.clearFocus()"))
assertTrue(source.contains("findFirstVisibleItemPosition()")) assertTrue(source.contains("findFirstVisibleItemPosition()"))
assertTrue(source.contains("viewModel.loadOlderMessages()")) assertTrue(source.contains("viewModel.loadOlderMessages()"))
assertTrue(source.contains("prependedMessageCountLiveData.observe(this)")) assertTrue(source.contains("prependedMessageCountLiveData.observe(this)"))
@@ -35,6 +42,10 @@ class DmChatRoomActivitySourceTest {
assertTrue(source.contains("viewModel.connectRealtime()")) assertTrue(source.contains("viewModel.connectRealtime()"))
assertTrue(source.contains("roomOpenedEventLiveData.observe(this)")) assertTrue(source.contains("roomOpenedEventLiveData.observe(this)"))
assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()")) assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()"))
assertTrue(source.contains("isRealtimeConnectingLiveData.observe(this)"))
assertTrue(source.contains("updateRealtimeLoadingDialog(isConnecting)"))
assertTrue(source.contains("loadingDialog.show(screenWidth)"))
assertTrue(source.contains("loadingDialog.dismiss()"))
assertTrue(source.contains("connectRealtimeIfStarted()")) assertTrue(source.contains("connectRealtimeIfStarted()"))
assertTrue(source.contains("viewModel.leaveRealtime()")) assertTrue(source.contains("viewModel.leaveRealtime()"))
assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()")) assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()"))
@@ -53,6 +64,80 @@ class DmChatRoomActivitySourceTest {
assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)")) assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)"))
} }
@Test
fun `DM 메시지 UI는 좌우 정렬 기존 버블 텍스트 전송만 유지한다`() {
val myMessage = projectFile(
"app/src/main/res/layout/item_dm_chat_my_message.xml"
).readText()
val opponentMessage = projectFile(
"app/src/main/res/layout/item_dm_chat_opponent_message.xml"
).readText()
val roomLayout = projectFile(
"app/src/main/res/layout/activity_dm_chat_room.xml"
).readText()
val activity = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt"
).readText()
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt"
).readText()
assertTrue(myMessage.contains("android:background=\"@drawable/bg_chat_user_message\""))
assertTrue(myMessage.contains("android:id=\"@+id/tv_time\""))
assertTrue(myMessage.contains("app:layout_constraintEnd_toStartOf=\"@id/message_container\""))
assertTrue(myMessage.contains("app:layout_constraintEnd_toEndOf=\"parent\""))
assertTrue(myMessage.contains("app:layout_constraintStart_toStartOf=\"parent\""))
assertTrue(myMessage.contains("app:layout_constraintStart_toEndOf=\"@id/tv_time\""))
assertTrue(myMessage.contains("app:layout_constraintHorizontal_bias=\"1\""))
assertTrue(myMessage.contains("app:layout_constraintHorizontal_chainStyle=\"packed\""))
assertTrue(myMessage.contains("app:layout_constrainedWidth=\"true\""))
assertTrue(opponentMessage.contains("android:background=\"@drawable/bg_chat_ai_message\""))
assertTrue(opponentMessage.contains("android:id=\"@+id/tv_time\""))
assertTrue(opponentMessage.contains("app:layout_constraintStart_toStartOf=\"parent\""))
assertTrue(opponentMessage.contains("app:layout_constraintStart_toEndOf=\"@id/iv_profile\""))
assertTrue(opponentMessage.contains("app:layout_constraintStart_toEndOf=\"@id/message_group\""))
assertTrue(adapter.contains("import kr.co.vividnext.sodalive.chat.talk.room.formatMessageTime"))
assertTrue(adapter.contains("tvTime.text = formatMessageTime(item.createdAt)"))
assertTrue(adapter.contains("MESSAGE_MAX_WIDTH_RATIO = 0.68f"))
assertTrue(roomLayout.contains("@+id/et_message"))
assertTrue(roomLayout.contains("@+id/iv_send"))
assertFalse(roomLayout.contains("voice"))
assertFalse(activity.contains("sendVoiceMessage"))
assertFalse(activity.contains("record"))
assertFalse(activity.contains("playVoice"))
}
@Test
fun `AppDI는 DM WebSocket raw JSON logger를 production 생성 경로에 주입한다`() {
val appDi = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt"
).readText()
assertTrue(appDi.contains("import com.orhanobut.logger.Logger"))
assertTrue(appDi.contains("rawJsonLogger = { rawJson ->"))
assertTrue(appDi.contains("Logger.d(\"DM WebSocket raw JSON: ${'$'}rawJson\")"))
assertFalse(appDi.contains("DmChatSocketClient(okHttpClient = get(), gson = get(), baseUrl = baseUrl)"))
}
@Test
fun `DM 채팅방 Activity 프로필 배경은 내 프로필 이미지를 사용하고 헤더는 상대 프로필을 유지한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt"
).readText()
val foregroundProfileBinding = source.substringAfter("binding.ivProfile.loadUrl(")
.substringBefore("binding.ivBackgroundProfile.loadUrl(")
val backgroundProfileBinding = source.substringAfter("binding.ivBackgroundProfile.loadUrl(")
.substringBefore("messageAdapter.submitItems")
assertTrue(source.contains("import kr.co.vividnext.sodalive.common.SharedPreferenceManager"))
assertTrue(foregroundProfileBinding.contains("state.opponentProfileImageUrl"))
assertTrue(foregroundProfileBinding.contains("transformations(CircleCropTransformation())"))
assertTrue(backgroundProfileBinding.contains("SharedPreferenceManager.profileImage"))
assertFalse(backgroundProfileBinding.contains("state.opponentProfileImageUrl"))
}
private fun projectFile(relativePath: String): File { private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath")) val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() } return candidates.firstOrNull { it.exists() }