From ec315c4747dd23f71321c6e42a44c18f91614e1c Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 20 Aug 2025 02:37:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-comment):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=8C=93=EA=B8=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D/=EB=AA=A9=EB=A1=9D/=EC=8B=A0=EA=B3=A0=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20DI=20=EB=93=B1=EB=A1=9D=20f?= =?UTF-8?q?ix(character-comment):=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=97=90=EC=84=9C=20cur?= =?UTF-8?q?sor=20null=20=EC=8B=9C=20=EC=B6=94=EA=B0=80=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterCommentApi/Repository 추가 - AppDI에 API/Repository 등록 - CharacterCommentListFragment: 등록 버튼 클릭 시 API 호출로 전환, 커서 페이징 목록 로드 적용, 신고 API 연동 - 로딩/에러 처리 및 중복 로드 방지 플래그 추가 - 스크롤 리스너에 canLoadMore 조건 추가(초기 또는 cursor 존재 시에만 호출) - loadMore()에 종료 가드 추가(adapter 비어있지 않고 cursor null이면 반환) - 댓글 1개인 경우 동일 내용 반복 로딩 문제 해결 --- .../character/comment/CharacterCommentApi.kt | 60 +++++++ .../comment/CharacterCommentListFragment.kt | 147 +++++++++++++----- .../comment/CharacterCommentRepository.kt | 73 +++++++++ .../detail/CharacterDetailActivity.kt | 34 ++++ .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 6 +- 5 files changed, 276 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentApi.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentApi.kt new file mode 100644 index 00000000..3727fcb8 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentApi.kt @@ -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> + + @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> + + @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> + + @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> + + @DELETE("/api/chat/character/{characterId}/comments/{commentId}") + fun deleteComment( + @Path("characterId") characterId: Long, + @Path("commentId") commentId: Long, + @Header("Authorization") authHeader: String + ): Single> + + @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> +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt index 1a78a652..3dddbc05 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt @@ -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::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 + 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 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 // 삭제 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 - 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 { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt new file mode 100644 index 00000000..4b6f1fb2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt @@ -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 + ) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt index 1c6660e7..b0c2bbc1 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt @@ -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::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( } 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) } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 40267cc0..231ef582 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -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()) } }