feat(character-comment): 답글 리스트 API 연동 및 커서 기반 무한 스크롤 적용

feat(character-comment): 답글 작성 API 연동 및 성공 시 낙관적 UI 반영

- CharacterCommentReplyFragment에 listReplies API 연동
- 초기 1회 로드 허용, 이후 cursor != null일 때만 추가 로드
- isLoading 플래그로 중복 요청 방지
- 어댑터 헤더(원본 댓글) 유지, replies만 순차 추가

- CharacterCommentReplyFragment에서 createReply API 호출로 스텁 제거
- 요청 중 로딩 다이얼로그 표시, 성공 시 입력 초기화 및 리스트에 즉시 추가
- 에러 처리(토스트) 적용
This commit is contained in:
2025-08-20 03:07:35 +09:00
parent b995a0b151
commit e881178f2a

View File

@@ -12,14 +12,14 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.load import coil.load
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentReplyBinding import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentReplyBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
/** /**
@@ -93,21 +93,49 @@ class CharacterCommentReplyFragment : BaseFragment<FragmentCharacterCommentReply
hideKeyboard() hideKeyboard()
val text = binding.etComment.text.toString() val text = binding.etComment.text.toString()
if (text.isBlank()) return@setOnClickListener if (text.isBlank()) return@setOnClickListener
// 스텁: 로컬에 즉시 추가 val originalId = original?.commentId ?: return@setOnClickListener
val me = CharacterReplyResponse( val token = "Bearer ${SharedPreferenceManager.token}"
replyId = System.currentTimeMillis(), loadingDialog.show(screenWidth)
memberId = SharedPreferenceManager.userId, val d = repository.createReply(
memberProfileImage = SharedPreferenceManager.profileImage, characterId = characterId,
memberNickname = SharedPreferenceManager.nickname, commentId = originalId,
createdAt = System.currentTimeMillis(), comment = text,
comment = text token = token
) )
val insertAt = adapter.items.size .subscribeOn(Schedulers.io())
adapter.items.add(me) .observeOn(AndroidSchedulers.mainThread())
adapter.notifyItemInserted(insertAt) .doFinally { loadingDialog.dismiss() }
binding.rvCommentReply.scrollToPosition(adapter.items.size - 1) .subscribe({ resp ->
binding.etComment.setText("") if (resp.success) {
Toast.makeText(requireContext(), "등록되었습니다 (stub)", Toast.LENGTH_SHORT).show() // 성공 시 낙관적 UI 반영: 기존 스텁과 동일하게 즉시 목록에 추가
val me = CharacterReplyResponse(
replyId = System.currentTimeMillis(),
memberId = SharedPreferenceManager.userId,
memberProfileImage = SharedPreferenceManager.profileImage,
memberNickname = SharedPreferenceManager.nickname,
createdAt = System.currentTimeMillis(),
comment = text
)
val insertAt = adapter.items.size
adapter.items.add(me)
adapter.notifyItemInserted(insertAt)
binding.rvCommentReply.scrollToPosition(adapter.items.size - 1)
binding.etComment.setText("")
} else {
Toast.makeText(
requireContext(),
resp.message ?: "요청 중 오류가 발생했습니다",
Toast.LENGTH_SHORT
).show()
}
}, { e ->
Toast.makeText(
requireContext(),
e.message ?: "요청 중 오류가 발생했습니다",
Toast.LENGTH_SHORT
).show()
})
compositeDisposable.add(d)
} }
adapter = CharacterCommentReplyAdapter( adapter = CharacterCommentReplyAdapter(
@@ -134,16 +162,30 @@ class CharacterCommentReplyFragment : BaseFragment<FragmentCharacterCommentReply
val recyclerView = binding.rvCommentReply val recyclerView = binding.rvCommentReply
recyclerView.setHasFixedSize(true) recyclerView.setHasFixedSize(true)
recyclerView.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false) recyclerView.layoutManager =
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() { recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state) super.getItemOffsets(outRect, view, parent, state)
outRect.left = 13.3f.dpToPx().toInt() outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt() outRect.right = 13.3f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) { when (parent.getChildAdapterPosition(view)) {
0 -> { outRect.top = 13.3f.dpToPx().toInt(); outRect.bottom = 12f.dpToPx().toInt() } 0 -> {
adapter.itemCount - 1 -> { outRect.top = 12f.dpToPx().toInt(); outRect.bottom = 13.3f.dpToPx().toInt() } outRect.top = 13.3f.dpToPx().toInt(); outRect.bottom = 12f.dpToPx().toInt()
else -> { outRect.top = 12f.dpToPx().toInt(); outRect.bottom = 12f.dpToPx().toInt() } }
adapter.itemCount - 1 -> {
outRect.top = 12f.dpToPx().toInt(); outRect.bottom = 13.3f.dpToPx().toInt()
}
else -> {
outRect.top = 12f.dpToPx().toInt(); outRect.bottom = 12f.dpToPx().toInt()
}
} }
} }
}) })
@@ -218,12 +260,16 @@ class CharacterCommentReplyFragment : BaseFragment<FragmentCharacterCommentReply
private const val EXTRA_CHARACTER_ID = "extra_character_id" private const val EXTRA_CHARACTER_ID = "extra_character_id"
private const val EXTRA_ORIGINAL_COMMENT_ID = "extra_original_comment_id" private const val EXTRA_ORIGINAL_COMMENT_ID = "extra_original_comment_id"
private const val EXTRA_ORIGINAL_MEMBER_ID = "extra_original_member_id" private const val EXTRA_ORIGINAL_MEMBER_ID = "extra_original_member_id"
private const val EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE = "extra_original_member_profile_image" private const val EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE =
"extra_original_member_profile_image"
private const val EXTRA_ORIGINAL_MEMBER_NICKNAME = "extra_original_member_nickname" private const val EXTRA_ORIGINAL_MEMBER_NICKNAME = "extra_original_member_nickname"
private const val EXTRA_ORIGINAL_CREATED_AT = "extra_original_created_at" private const val EXTRA_ORIGINAL_CREATED_AT = "extra_original_created_at"
private const val EXTRA_ORIGINAL_REPLY_COUNT = "extra_original_reply_count" private const val EXTRA_ORIGINAL_REPLY_COUNT = "extra_original_reply_count"
private const val EXTRA_ORIGINAL_COMMENT_TEXT = "extra_original_comment_text" private const val EXTRA_ORIGINAL_COMMENT_TEXT = "extra_original_comment_text"
fun newInstance(characterId: Long, original: CharacterCommentResponse): CharacterCommentReplyFragment { fun newInstance(
characterId: Long,
original: CharacterCommentResponse
): CharacterCommentReplyFragment {
val args = Bundle().apply { val args = Bundle().apply {
putLong(EXTRA_CHARACTER_ID, characterId) putLong(EXTRA_CHARACTER_ID, characterId)
putLong(EXTRA_ORIGINAL_COMMENT_ID, original.commentId) putLong(EXTRA_ORIGINAL_COMMENT_ID, original.commentId)