feat(chat-room): 메시지 입력/전송/실패 처리(6.1~6.3) 구현

- 왜: 채팅방에서 메시지 입력/전송 및 오류 대응 UX 완성을 위해 6.x 과업을 구현했습니다.
- 무엇:
  - 6.1 입력창 UI
    - EditText placeholder 리소스(@string/chat_input_placeholder) 적용, 최대 200자 제한
    - imeOptions(actionSend|flagNoEnterAction)로 IME 전송 액션 지원
    - 전송 버튼 활성/비활성 상태 관리(TextWatcher), 접근성 라벨(@string/action_send)
    - 입력창 포커스/클릭 시 키보드 표시, 전송 후 키보드 숨김
  - 6.2 전송 플로우
    - onSendClicked()/sendMessage() 도입: 즉시 SENDING 상태로 사용자 메시지 추가
    - 타이핑 인디케이터 표시/숨김 제어(ChatMessageAdapter.show/hideTypingIndicator)
    - 성공 시뮬레이션 후 SENT로 상태 업데이트 및 AI 응답 메시지 추가
    - TODO: 실제 TalkApi POST 연동 지점 주석 추가
  - 6.3 전송 실패 처리
    - FAILED 상태 시 사용자 메시지에 재전송 버튼 노출(item_chat_user_message.xml: iv_retry)
    - 어댑터 콜백을 통한 onRetrySend(localId) 처리 → 재시도 시 SENDING → SENT(성공 시)로 전환
    - strings: action_retry 추가, 접근성 라벨 적용
This commit is contained in:
2025-08-13 23:10:32 +09:00
parent 0cf0d2e790
commit ceae25ea06
5 changed files with 234 additions and 8 deletions

View File

@@ -37,6 +37,16 @@ sealed class ChatListItem {
class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
interface Callback {
fun onRetrySend(localId: String)
}
private var callback: Callback? = null
fun setCallback(cb: Callback?) {
callback = cb
}
companion object { companion object {
const val VIEW_TYPE_USER_MESSAGE = 1 const val VIEW_TYPE_USER_MESSAGE = 1
const val VIEW_TYPE_AI_MESSAGE = 2 const val VIEW_TYPE_AI_MESSAGE = 2
@@ -214,6 +224,22 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
binding.messageContainer.alpha = alpha binding.messageContainer.alpha = alpha
// 실패 시 재전송 버튼 표시/클릭 연결
val showRetry = data.status == MessageStatus.FAILED && !data.localId.isNullOrEmpty()
binding.ivRetry.isVisible = showRetry
if (showRetry) {
binding.ivRetry.setOnClickListener {
data.localId?.let { lid ->
(binding.root.parent as? RecyclerView)?.let {
// 콜백 호출
(it.adapter as? ChatMessageAdapter)?.callback?.onRetrySend(lid)
}
}
}
} else {
binding.ivRetry.setOnClickListener(null)
}
// 그룹 내부 간격 최소화 (상단 패딩 축소) // 그룹 내부 간격 최소화 (상단 패딩 축소)
adjustTopPadding(isGrouped) adjustTopPadding(isGrouped)

View File

@@ -3,6 +3,12 @@ package kr.co.vividnext.sodalive.chat.talk.room
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent 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.core.content.edit
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -11,7 +17,6 @@ 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.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
@@ -30,7 +35,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
private var characterInfo: CharacterInfo? = null private var characterInfo: CharacterInfo? = null
// 5.4 SharedPreferences (안내 메시지 접힘 상태 저장) // 5.4 SharedPreferences (안내 메시지 접힘 상태 저장)
private val prefs by lazy { getSharedPreferences("chat_room_prefs", Context.MODE_PRIVATE) } private val prefs by lazy { getSharedPreferences("chat_room_prefs", MODE_PRIVATE) }
private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${'$'}roomId" private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${'$'}roomId"
private fun isNoticeHidden(): Boolean = prefs.getBoolean(noticePrefKey(roomId), false) private fun isNoticeHidden(): Boolean = prefs.getBoolean(noticePrefKey(roomId), false)
private fun setNoticeHidden(hidden: Boolean) { private fun setNoticeHidden(hidden: Boolean) {
@@ -115,7 +120,13 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
} }
binding.rvMessages.layoutManager = layoutManager binding.rvMessages.layoutManager = layoutManager
chatAdapter = ChatMessageAdapter() chatAdapter = ChatMessageAdapter().apply {
setCallback(object : ChatMessageAdapter.Callback {
override fun onRetrySend(localId: String) {
onRetrySendClicked(localId)
}
})
}
binding.rvMessages.adapter = chatAdapter binding.rvMessages.adapter = chatAdapter
// 상단 도달 시 이전 메시지 로드 // 상단 도달 시 이전 메시지 로드
@@ -131,9 +142,55 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
} }
private fun setupInputArea() { private fun setupInputArea() {
// 6.x에서 전송 활성화/키보드 처리/입력 검증 구현 예정 // 전송 버튼 접근성 라벨
// 안전한 초기 상태: 전송 버튼 접근성 라벨만 지정 binding.ivSend.contentDescription = getString(R.string.action_send)
binding.ivSend.contentDescription = "전송"
// 초기 상태: 비활성화
setSendButtonEnabled(false)
// 입력값 변동 시 전송 버튼 활성/비활성
binding.etMessage.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
val hasText = !s.isNullOrBlank()
setSendButtonEnabled(hasText)
}
override fun afterTextChanged(s: Editable?) {}
})
// 입력창 포커스 시 키보드 표시
binding.etMessage.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus) showKeyboard(v)
}
// 입력창 클릭 시 포커스 및 키보드 보장
binding.etMessage.setOnClickListener { v ->
v.requestFocus()
showKeyboard(v)
}
// IME Send 액션 시 전송 버튼 클릭 트리거 (6.2에서 실제 전송 구현)
binding.etMessage.setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) {
if (binding.ivSend.isEnabled) {
binding.ivSend.performClick()
}
true
} else {
false
}
}
// 전송 버튼 클릭 시 (6.2: 실제 전송 플로우 구현)
binding.ivSend.setOnClickListener {
onSendClicked()
}
}
private fun setSendButtonEnabled(enabled: Boolean) {
binding.ivSend.isEnabled = enabled
binding.ivSend.alpha = if (enabled) 1.0f else 0.4f
} }
// region 5.4 Notice Area // region 5.4 Notice Area
@@ -163,6 +220,133 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
} }
// endregion 5.4 Notice Area // endregion 5.4 Notice Area
// region 6.1 Keyboard helpers
private fun showKeyboard(view: View) {
view.post {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideKeyboard(view: View) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
}
// endregion 6.1 Keyboard helpers
// region 6.2 Send flow
private fun onSendClicked() {
val content = binding.etMessage.text?.toString()?.trim().orEmpty()
if (content.isBlank()) return
// 입력창 초기화 및 키보드 숨김
hideKeyboard(binding.etMessage)
binding.etMessage.setText("")
setSendButtonEnabled(false)
// 전송 처리
sendMessage(content)
}
private fun sendMessage(content: String) {
val localId = java.util.UUID.randomUUID().toString()
val now = System.currentTimeMillis()
// 1) 사용자 메시지를 즉시 UI에 추가 (SENDING)
val userMsg = ChatMessage(
messageId = -now, // 임시 음수 ID로 충돌 회피
message = content,
profileImageUrl = "",
mine = true,
createdAt = now,
status = MessageStatus.SENDING,
localId = localId,
isGrouped = false
)
appendMessage(ChatListItem.UserMessage(userMsg))
// 2) 타이핑 인디케이터 표시
chatAdapter.showTypingIndicator()
// 3) 서버 전송 호출 (TODO: TalkApi 연동)
// TODO: ChatRepository를 통해 메시지 전송 API 연동 (9.x에서 구현)
// 4) 전송 성공/실패 시뮬레이션
binding.rvMessages.postDelayed({
val success = java.util.Random().nextBoolean()
if (success) {
updateUserMessageStatus(localId, MessageStatus.SENT)
addAiReply(content)
} else {
chatAdapter.hideTypingIndicator()
updateUserMessageStatus(localId, MessageStatus.FAILED)
// TODO: 로컬 DB에 FAILED 상태 저장 (7.x/9.x 연동 시 구현)
}
}, 1000)
}
private fun updateUserMessageStatus(localId: String, newStatus: MessageStatus) {
val index = items.indexOfLast {
(it is ChatListItem.UserMessage) && it.data.localId == localId
}
if (index >= 0) {
val old = (items[index] as ChatListItem.UserMessage).data
val updated = old.copy(status = newStatus)
items[index] = ChatListItem.UserMessage(updated)
chatAdapter.setItems(items)
autoScrollIfAtBottom()
}
}
private fun onRetrySendClicked(localId: String) {
// 실패한 메시지를 SENDING으로 변경하고 재시도 시뮬레이션
val index = items.indexOfLast {
(it is ChatListItem.UserMessage) && it.data.localId == localId
}
if (index < 0) return
val item = items[index] as ChatListItem.UserMessage
val content = item.data.message
// 상태를 SENDING으로 변경
items[index] = ChatListItem.UserMessage(item.data.copy(status = MessageStatus.SENDING))
chatAdapter.setItems(items)
autoScrollIfAtBottom()
// 타이핑 인디케이터 표시
chatAdapter.showTypingIndicator()
// 재전송 성공/실패 시뮬레이션
binding.rvMessages.postDelayed({
val success = true // 재시도는 성공 확률을 높게 가정(데모용)
if (success) {
updateUserMessageStatus(localId, MessageStatus.SENT)
addAiReply(content)
} else {
chatAdapter.hideTypingIndicator()
updateUserMessageStatus(localId, MessageStatus.FAILED)
}
}, 900)
}
private fun addAiReply(userContent: String) {
// 5) 타이핑 인디케이터 제거 및 AI 응답 추가
chatAdapter.hideTypingIndicator()
val now = System.currentTimeMillis()
val replyText = "안녕하세요! \"$userContent\"에 대한 답변입니다."
val aiMsg = ChatMessage(
messageId = now,
message = replyText,
profileImageUrl = characterInfo?.profileImageUrl ?: "",
mine = false,
createdAt = now,
status = MessageStatus.SENT
)
appendMessage(ChatListItem.AiMessage(aiMsg, characterInfo?.name))
}
// endregion 6.2 Send flow
private fun loadInitialMessages() { private fun loadInitialMessages() {
// 7.x에서 Repository 연동 및 초기 로딩 구현 예정 // 7.x에서 Repository 연동 및 초기 로딩 구현 예정
items.clear() items.clear()

View File

@@ -189,13 +189,14 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="16dp" android:paddingEnd="16dp"
android:hint="메세지를 입력하세요." android:hint="@string/chat_input_placeholder"
android:maxLength="200" android:maxLength="200"
android:maxLines="4" android:maxLines="4"
android:background="@drawable/bg_chat_input" android:background="@drawable/bg_chat_input"
android:textColor="#FFFFFFFF" android:textColor="#FFFFFFFF"
android:textColorHint="#80FFFFFF" android:textColorHint="#80FFFFFF"
android:inputType="textMultiLine" android:inputType="textMultiLine"
android:imeOptions="actionSend|flagNoEnterAction"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/iv_send" app:layout_constraintEnd_toStartOf="@id/iv_send"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View File

@@ -3,6 +3,7 @@
사용자 메시지 아이템 레이아웃 사용자 메시지 아이템 레이아웃
- 오른쪽 정렬된 메시지 버블 - 오른쪽 정렬된 메시지 버블
- 버블의 왼쪽에 시간 표시 - 버블의 왼쪽에 시간 표시
- 실패 시 재전송 버튼 노출
- 배경: @drawable/bg_chat_user_message - 배경: @drawable/bg_chat_user_message
--> -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
@@ -58,6 +59,18 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<!-- 재전송 버튼: FAILED 상태일 때만 보임 -->
<ImageView
android:id="@+id/iv_retry"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="6dp"
android:layout_marginTop="-8dp"
android:src="@android:drawable/ic_popup_sync"
android:background="@android:color/transparent"
android:contentDescription="@string/action_retry"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -17,4 +17,6 @@
<string name="chat_notice_clone">AI Clone은 크리에이터의 정보를 기반으로 대화하지만, 모든 정보를 완벽하게 반영하거나 실제 대화와 일치하지 않을 수 있습니다.</string> <string name="chat_notice_clone">AI Clone은 크리에이터의 정보를 기반으로 대화하지만, 모든 정보를 완벽하게 반영하거나 실제 대화와 일치하지 않을 수 있습니다.</string>
<string name="chat_notice_character">보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다.</string> <string name="chat_notice_character">보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다.</string>
<string name="chat_input_placeholder">메세지를 입력하세요.</string> <string name="chat_input_placeholder">메세지를 입력하세요.</string>
<string name="action_send">전송</string>
<string name="action_retry">다시 전송</string>
</resources> </resources>