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.transform.CircleCropTransformation
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.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBinding>(
FragmentCharacterCommentListBinding::inflate
) {
private val repository: CharacterCommentRepository by inject()
private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: CharacterCommentsAdapter
@@ -48,8 +53,8 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
setupView()
bindData()
// 초기 로드 (스텁)
loadMore()
// 초기 로드
resetAndLoad()
}
private fun hideDialog() {
@@ -70,21 +75,31 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
hideKeyboard()
val comment = binding.etComment.text.toString()
if (comment.isBlank()) return@setOnClickListener
// 스텁: 로컬에 즉시 추가 (CharacterCommentDto 기반)
val me = CharacterCommentResponse(
commentId = System.currentTimeMillis(),
memberId = SharedPreferenceManager.userId,
memberProfileImage = SharedPreferenceManager.profileImage,
memberNickname = SharedPreferenceManager.nickname,
createdAt = System.currentTimeMillis(),
replyCount = 0,
comment = comment
)
adapter.items.add(me)
adapter.notifyItemInserted(adapter.items.size - 1)
binding.rvComment.scrollToPosition(adapter.items.size - 1)
binding.etComment.setText("")
Toast.makeText(requireContext(), "등록되었습니다 (stub)", Toast.LENGTH_SHORT).show()
val token = "Bearer ${SharedPreferenceManager.token}"
loadingDialog.show(screenWidth)
val d = repository.createComment(characterId, comment, token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doFinally { loadingDialog.dismiss() }
.subscribe({ resp ->
if (resp.success) {
binding.etComment.setText("")
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(
@@ -92,11 +107,35 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
onClickMore = { item, isOwner, anchor ->
CharacterCommentMoreBottomSheet.newInstance(isOwner).apply {
onReport = {
// 더보기 닫히고 신고 BottomSheet 열림
val reportSheet = CharacterCommentReportBottomSheet.newInstance()
reportSheet.onSubmit = { reason ->
// 신고 API 스텁 호출 지점
Toast.makeText(requireContext(), "신고 접수: $reason (stub)", Toast.LENGTH_SHORT).show()
val token = "Bearer ${SharedPreferenceManager.token}"
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")
}
@@ -107,7 +146,8 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
.setMessage(getString(R.string.confirm_delete_message))
.setPositiveButton(getString(R.string.confirm)) { _, _ ->
// 삭제 API 스텁 호출 지점
val index = adapter.items.indexOfFirst { it.commentId == item.commentId }
val index =
adapter.items.indexOfFirst { it.commentId == item.commentId }
if (index >= 0) {
adapter.items.removeAt(index)
adapter.notifyItemRemoved(index)
@@ -167,7 +207,9 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
val lastVisible =
(recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()
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()
}
}
@@ -186,28 +228,47 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
imm.hideSoftInputFromWindow(view?.windowToken, 0)
}
private var page = 1
private var cursor: Long? = null
private var isLoading = false
private fun resetAndLoad() {
cursor = null
adapter.items.clear()
adapter.notifyDataSetChanged()
loadMore()
}
private fun loadMore() {
// API 스텁: 더미 데이터 생성
val newItems = (1..10).map { idx ->
val id = page * 1000L + idx
val writerId = if (idx % 5 == 0) SharedPreferenceManager.userId else -idx.toLong()
CharacterCommentResponse(
commentId = id,
memberId = writerId,
memberProfileImage = "",
memberNickname = "게스트$id",
createdAt = System.currentTimeMillis() - (idx * 60_000L * page),
replyCount = (idx % 3),
comment = "캐릭터 댓글 예시 텍스트 $id"
)
}
if (page == 1) adapter.items.clear()
val start = adapter.items.size
adapter.items.addAll(newItems)
adapter.notifyItemRangeInserted(start, newItems.size)
binding.tvCommentCount.text = "${adapter.items.size}"
page++
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
adapter.items.addAll(items)
adapter.notifyItemRangeInserted(start, items.size)
binding.tvCommentCount.text = "${adapter.items.size}"
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 {

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.extensions.dpToPx
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>(
ActivityCharacterDetailBinding::inflate
) {
private val viewModel: CharacterDetailViewModel by viewModel()
private val commentRepository: CharacterCommentRepository by inject()
private lateinit var loadingDialog: LoadingDialog
@@ -283,6 +288,35 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
}
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.CharacterTabRepository
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.CharacterDetailViewModel
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
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.ObjectBox
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.MemberTagViewModel
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.FaqRepository
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(), CharacterApi::class.java) }
single { ApiBuilder().build(get(), TalkApi::class.java) }
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
}
private val viewModelModule = module {
@@ -403,6 +406,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { CharacterTabRepository(get()) }
factory { CharacterDetailRepository(get(), get()) }
factory { TalkTabRepository(get()) }
factory { CharacterCommentRepository(get()) }
}