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:
@@ -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>>
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user