feat(character-comment): 캐릭터 댓글 리스트 등록/목록/신고 API 연동 및 DI 등록

fix(character-comment): 캐릭터 댓글 리스트 무한 스크롤에서 cursor null 시 추가 호출 방지

- CharacterCommentApi/Repository 추가
- AppDI에 API/Repository 등록
- CharacterCommentListFragment: 등록 버튼 클릭 시 API 호출로 전환, 커서 페이징 목록 로드 적용, 신고 API 연동
- 로딩/에러 처리 및 중복 로드 방지 플래그 추가

- 스크롤 리스너에 canLoadMore 조건 추가(초기 또는 cursor 존재 시에만 호출)
- loadMore()에 종료 가드 추가(adapter 비어있지 않고 cursor null이면 반환)
- 댓글 1개인 경우 동일 내용 반복 로딩 문제 해결
This commit is contained in:
2025-08-20 02:37:14 +09:00
parent 52ff0c82cb
commit ec315c4747
5 changed files with 276 additions and 44 deletions

View File

@@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.chat.character.comment
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface CharacterCommentApi {
@POST("/api/chat/character/{characterId}/comments")
fun createComment(
@Path("characterId") characterId: Long,
@Body request: CreateCharacterCommentRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@POST("/api/chat/character/{characterId}/comments/{commentId}/replies")
fun createReply(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Body request: CreateCharacterCommentRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/api/chat/character/{characterId}/comments")
fun listComments(
@Path("characterId") characterId: Long,
@Query("limit") limit: Int = 20,
@Query("cursor") cursor: Long?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CharacterCommentListResponse>>
@GET("/api/chat/character/{characterId}/comments/{commentId}/replies")
fun listReplies(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Query("limit") limit: Int = 20,
@Query("cursor") cursor: Long?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CharacterCommentRepliesResponse>>
@DELETE("/api/chat/character/{characterId}/comments/{commentId}")
fun deleteComment(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@POST("/api/chat/character/{characterId}/comments/{commentId}/reports")
fun reportComment(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Body request: ReportCharacterCommentRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
}

View File

@@ -14,17 +14,22 @@ import androidx.recyclerview.widget.RecyclerView
import coil.load import coil.load
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
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.FragmentCharacterCommentListBinding import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBinding>( class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBinding>(
FragmentCharacterCommentListBinding::inflate FragmentCharacterCommentListBinding::inflate
) { ) {
private val repository: CharacterCommentRepository by inject()
private lateinit var imm: InputMethodManager private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: CharacterCommentsAdapter private lateinit var adapter: CharacterCommentsAdapter
@@ -48,8 +53,8 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
setupView() setupView()
bindData() bindData()
// 초기 로드 (스텁) // 초기 로드
loadMore() resetAndLoad()
} }
private fun hideDialog() { private fun hideDialog() {
@@ -70,21 +75,31 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
hideKeyboard() hideKeyboard()
val comment = binding.etComment.text.toString() val comment = binding.etComment.text.toString()
if (comment.isBlank()) return@setOnClickListener if (comment.isBlank()) return@setOnClickListener
// 스텁: 로컬에 즉시 추가 (CharacterCommentDto 기반) val token = "Bearer ${SharedPreferenceManager.token}"
val me = CharacterCommentResponse( loadingDialog.show(screenWidth)
commentId = System.currentTimeMillis(), val d = repository.createComment(characterId, comment, token)
memberId = SharedPreferenceManager.userId, .subscribeOn(Schedulers.io())
memberProfileImage = SharedPreferenceManager.profileImage, .observeOn(AndroidSchedulers.mainThread())
memberNickname = SharedPreferenceManager.nickname, .doFinally { loadingDialog.dismiss() }
createdAt = System.currentTimeMillis(), .subscribe({ resp ->
replyCount = 0, if (resp.success) {
comment = comment
)
adapter.items.add(me)
adapter.notifyItemInserted(adapter.items.size - 1)
binding.rvComment.scrollToPosition(adapter.items.size - 1)
binding.etComment.setText("") binding.etComment.setText("")
Toast.makeText(requireContext(), "등록되었습니다 (stub)", Toast.LENGTH_SHORT).show() resetAndLoad()
} else {
Toast.makeText(
requireContext(),
resp.message ?: "요청 중 오류가 발생했습니다",
Toast.LENGTH_SHORT
).show()
}
}, { e ->
Toast.makeText(
requireContext(),
e.message ?: "요청 중 오류가 발생했습니다",
Toast.LENGTH_SHORT
).show()
})
compositeDisposable.add(d)
} }
adapter = CharacterCommentsAdapter( adapter = CharacterCommentsAdapter(
@@ -92,11 +107,35 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
onClickMore = { item, isOwner, anchor -> onClickMore = { item, isOwner, anchor ->
CharacterCommentMoreBottomSheet.newInstance(isOwner).apply { CharacterCommentMoreBottomSheet.newInstance(isOwner).apply {
onReport = { onReport = {
// 더보기 닫히고 신고 BottomSheet 열림
val reportSheet = CharacterCommentReportBottomSheet.newInstance() val reportSheet = CharacterCommentReportBottomSheet.newInstance()
reportSheet.onSubmit = { reason -> reportSheet.onSubmit = { reason ->
// 신고 API 스텁 호출 지점 val token = "Bearer ${SharedPreferenceManager.token}"
Toast.makeText(requireContext(), "신고 접수: $reason (stub)", Toast.LENGTH_SHORT).show() val d =
repository.reportComment(characterId, item.commentId, reason, token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
if (resp.success) {
Toast.makeText(
requireContext(),
"신고가 접수되었습니다.",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
requireContext(),
resp.message ?: "요청 중 오류가 발생했습니다",
Toast.LENGTH_SHORT
).show()
}
}, { e ->
Toast.makeText(
requireContext(),
e.message ?: "요청 중 오류가 발생했습니다",
Toast.LENGTH_SHORT
).show()
})
compositeDisposable.add(d)
} }
reportSheet.show(childFragmentManager, "comment_report") reportSheet.show(childFragmentManager, "comment_report")
} }
@@ -107,7 +146,8 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
.setMessage(getString(R.string.confirm_delete_message)) .setMessage(getString(R.string.confirm_delete_message))
.setPositiveButton(getString(R.string.confirm)) { _, _ -> .setPositiveButton(getString(R.string.confirm)) { _, _ ->
// 삭제 API 스텁 호출 지점 // 삭제 API 스텁 호출 지점
val index = adapter.items.indexOfFirst { it.commentId == item.commentId } val index =
adapter.items.indexOfFirst { it.commentId == item.commentId }
if (index >= 0) { if (index >= 0) {
adapter.items.removeAt(index) adapter.items.removeAt(index)
adapter.notifyItemRemoved(index) adapter.notifyItemRemoved(index)
@@ -167,7 +207,9 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
val lastVisible = val lastVisible =
(recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition() (recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()
val total = recyclerView.adapter?.itemCount ?: 0 val total = recyclerView.adapter?.itemCount ?: 0
if (!recyclerView.canScrollVertically(1) && lastVisible == total - 1) { // 초기 진입(아이템 없음) 또는 다음 페이지가 존재할 때(cursor != null)에만 로드
val canLoadMore = adapter.items.isEmpty() || cursor != null
if (canLoadMore && !recyclerView.canScrollVertically(1) && lastVisible == total - 1) {
loadMore() loadMore()
} }
} }
@@ -186,28 +228,47 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
imm.hideSoftInputFromWindow(view?.windowToken, 0) imm.hideSoftInputFromWindow(view?.windowToken, 0)
} }
private var page = 1 private var cursor: Long? = null
private fun loadMore() { private var isLoading = false
// API 스텁: 더미 데이터 생성
val newItems = (1..10).map { idx -> private fun resetAndLoad() {
val id = page * 1000L + idx cursor = null
val writerId = if (idx % 5 == 0) SharedPreferenceManager.userId else -idx.toLong() adapter.items.clear()
CharacterCommentResponse( adapter.notifyDataSetChanged()
commentId = id, loadMore()
memberId = writerId,
memberProfileImage = "",
memberNickname = "게스트$id",
createdAt = System.currentTimeMillis() - (idx * 60_000L * page),
replyCount = (idx % 3),
comment = "캐릭터 댓글 예시 텍스트 $id"
)
} }
if (page == 1) adapter.items.clear()
private fun loadMore() {
if (isLoading) return
// 초기 로드(아이템 없음)는 허용. 그 외에는 cursor가 null이면 더 이상 로드하지 않음
if (adapter.items.isNotEmpty() && cursor == null) return
val token = "Bearer ${SharedPreferenceManager.token}"
isLoading = true
val d = repository.listComments(characterId, 20, cursor, token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doFinally { isLoading = false }
.subscribe({ resp ->
if (resp.success) {
val data = resp.data
val items = data?.comments ?: emptyList()
val start = adapter.items.size val start = adapter.items.size
adapter.items.addAll(newItems) adapter.items.addAll(items)
adapter.notifyItemRangeInserted(start, newItems.size) adapter.notifyItemRangeInserted(start, items.size)
binding.tvCommentCount.text = "${adapter.items.size}" binding.tvCommentCount.text = "${adapter.items.size}"
page++ cursor = data?.cursor
} else {
Toast.makeText(
requireContext(),
resp.message ?: "요청 중 오류가 발생했습니다",
Toast.LENGTH_SHORT
).show()
}
}, { e ->
Toast.makeText(requireContext(), e.message ?: "요청 중 오류가 발생했습니다", Toast.LENGTH_SHORT)
.show()
})
compositeDisposable.add(d)
} }
companion object { companion object {

View File

@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.chat.character.comment
class CharacterCommentRepository(private val api: CharacterCommentApi) {
fun createComment(
characterId: Long,
comment: String,
token: String
) = api.createComment(
characterId = characterId,
request = CreateCharacterCommentRequest(comment = comment),
authHeader = token
)
fun createReply(
characterId: Long,
commentId: Long,
comment: String,
token: String
) = api.createReply(
characterId = characterId,
commentId = commentId,
request = CreateCharacterCommentRequest(comment = comment),
authHeader = token
)
fun listComments(
characterId: Long,
limit: Int,
cursor: Long?,
token: String
) = api.listComments(
characterId = characterId,
limit = limit,
cursor = cursor,
authHeader = token
)
fun listReplies(
characterId: Long,
commentId: Long,
limit: Int,
cursor: Long?,
token: String
) = api.listReplies(
characterId = characterId,
commentId = commentId,
limit = limit,
cursor = cursor,
authHeader = token
)
fun deleteComment(
characterId: Long,
commentId: Long,
token: String
) = api.deleteComment(
characterId = characterId,
commentId = commentId,
authHeader = token
)
fun reportComment(
characterId: Long,
commentId: Long,
reason: String,
token: String
) = api.reportComment(
characterId = characterId,
commentId = commentId,
request = ReportCharacterCommentRequest(content = reason),
authHeader = token
)
}

View File

@@ -20,11 +20,16 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityCharacterDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityCharacterDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.android.ext.android.inject
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>( class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
ActivityCharacterDetailBinding::inflate ActivityCharacterDetailBinding::inflate
) { ) {
private val viewModel: CharacterDetailViewModel by viewModel() private val viewModel: CharacterDetailViewModel by viewModel()
private val commentRepository: CharacterCommentRepository by inject()
private lateinit var loadingDialog: LoadingDialog private lateinit var loadingDialog: LoadingDialog
@@ -283,6 +288,35 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
} }
binding.ivSendComment.setOnClickListener { binding.ivSendComment.setOnClickListener {
val text = binding.etCommentInput.text?.toString()?.trim().orEmpty()
if (text.isBlank()) return@setOnClickListener
val idFromState = viewModel.uiState.value?.detail?.characterId ?: 0L
val idFromIntent = intent.getLongExtra(EXTRA_CHARACTER_ID, 0L)
val characterId = if (idFromState > 0) idFromState else idFromIntent
if (characterId <= 0) {
showToast("잘못된 접근 입니다.")
return@setOnClickListener
}
val token = "Bearer ${SharedPreferenceManager.token}"
loadingDialog.show(screenWidth)
val d = commentRepository.createComment(characterId, text, token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doFinally { loadingDialog.dismiss() }
.subscribe({ resp ->
if (resp.success) {
binding.etCommentInput.setText("")
showToast("등록되었습니다.")
viewModel.load(characterId)
} else {
showToast(resp.message ?: "요청 중 오류가 발생했습니다")
}
}, { e ->
showToast(e.message ?: "요청 중 오류가 발생했습니다")
})
compositeDisposable.add(d)
} }
} }
} }

View File

@@ -67,11 +67,14 @@ import kr.co.vividnext.sodalive.audition.role.AuditionRoleDetailViewModel
import kr.co.vividnext.sodalive.chat.character.CharacterApi import kr.co.vividnext.sodalive.chat.character.CharacterApi
import kr.co.vividnext.sodalive.chat.character.CharacterTabRepository import kr.co.vividnext.sodalive.chat.character.CharacterTabRepository
import kr.co.vividnext.sodalive.chat.character.CharacterTabViewModel import kr.co.vividnext.sodalive.chat.character.CharacterTabViewModel
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentApi
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailRepository import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailRepository
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailViewModel import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailViewModel
import kr.co.vividnext.sodalive.chat.talk.TalkApi import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
import kr.co.vividnext.sodalive.chat.talk.room.chatTalkRoomModule
import kr.co.vividnext.sodalive.common.ApiBuilder import kr.co.vividnext.sodalive.common.ApiBuilder
import kr.co.vividnext.sodalive.common.ObjectBox import kr.co.vividnext.sodalive.common.ObjectBox
import kr.co.vividnext.sodalive.explorer.ExplorerApi import kr.co.vividnext.sodalive.explorer.ExplorerApi
@@ -146,7 +149,6 @@ import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagApi
import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagRepository import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagRepository
import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagViewModel import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagViewModel
import kr.co.vividnext.sodalive.mypage.recent.recentContentModule import kr.co.vividnext.sodalive.mypage.recent.recentContentModule
import kr.co.vividnext.sodalive.chat.talk.room.chatTalkRoomModule
import kr.co.vividnext.sodalive.mypage.service_center.FaqApi import kr.co.vividnext.sodalive.mypage.service_center.FaqApi
import kr.co.vividnext.sodalive.mypage.service_center.FaqRepository import kr.co.vividnext.sodalive.mypage.service_center.FaqRepository
import kr.co.vividnext.sodalive.mypage.service_center.ServiceCenterViewModel import kr.co.vividnext.sodalive.mypage.service_center.ServiceCenterViewModel
@@ -256,6 +258,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
single { ApiBuilder().build(get(), HomeApi::class.java) } single { ApiBuilder().build(get(), HomeApi::class.java) }
single { ApiBuilder().build(get(), CharacterApi::class.java) } single { ApiBuilder().build(get(), CharacterApi::class.java) }
single { ApiBuilder().build(get(), TalkApi::class.java) } single { ApiBuilder().build(get(), TalkApi::class.java) }
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
} }
private val viewModelModule = module { private val viewModelModule = module {
@@ -403,6 +406,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { CharacterTabRepository(get()) } factory { CharacterTabRepository(get()) }
factory { CharacterDetailRepository(get(), get()) } factory { CharacterDetailRepository(get(), get()) }
factory { TalkTabRepository(get()) } factory { TalkTabRepository(get()) }
factory { CharacterCommentRepository(get()) }
} }