From ccd88dad47d69fae97d20204306f90d9648a7c1b Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 20 Aug 2025 16:22:34 +0900 Subject: [PATCH] =?UTF-8?q?refactor(chat/character):=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?ViewModel=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20Fragment-Repositor?= =?UTF-8?q?y=20=EC=A7=81=EC=A0=91=20=EC=9D=98=EC=A1=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CharacterCommentListViewModel을 추가하여 댓글 조회/등록/삭제/신고 및 페이지네이션 로직을 ViewModel로 이전. Fragment는 UI 업데이트와 사용자 입력 처리에 집중하도록 리팩토링. Koin DI에 ViewModel 등록. --- .../comment/CharacterCommentListFragment.kt | 177 ++++------------ .../comment/CharacterCommentListViewModel.kt | 197 ++++++++++++++++++ .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 1 + 3 files changed, 236 insertions(+), 139 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListViewModel.kt 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 e036eafc..d44f287d 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 @@ -8,14 +8,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager -import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.LinearLayoutManager 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 @@ -28,7 +26,7 @@ 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) + viewModel.createComment(characterId, comment) + binding.etComment.setText("") } adapter = CharacterCommentsAdapter( @@ -109,74 +84,20 @@ class CharacterCommentListFragment : BaseFragment - 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) + viewModel.reportComment(characterId, item.commentId, reason) } reportSheet.show(parentFragmentManager, "comment_report") } onDelete = { // 삭제 확인 팝업 - androidx.appcompat.app.AlertDialog.Builder(requireContext()) + AlertDialog.Builder(requireContext()) .setTitle(getString(R.string.confirm_delete_title)) .setMessage(getString(R.string.confirm_delete_message)) .setPositiveButton(getString(R.string.confirm)) { _, _ -> - val token = "Bearer ${SharedPreferenceManager.token}" - loadingDialog.show(screenWidth) - val d = repository.deleteComment( + viewModel.deleteComment( characterId = characterId, - commentId = item.commentId, - token = token + commentId = item.commentId ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doFinally { loadingDialog.dismiss() } - .subscribe({ resp -> - if (resp.success) { - val index = adapter.items.indexOfFirst { it.commentId == item.commentId } - if (index >= 0) { - adapter.items.removeAt(index) - adapter.notifyItemRemoved(index) - } - } else { - Toast.makeText( - requireContext(), - resp.message ?: "요청 중 오류가 발생했습니다", - Toast.LENGTH_SHORT - ).show() - } - }, { e -> - Toast.makeText( - requireContext(), - e.message ?: "요청 중 오류가 발생했습니다", - Toast.LENGTH_SHORT - ).show() - }) - compositeDisposable.add(d) } .setNegativeButton(getString(R.string.cancel), null) .show() @@ -229,13 +150,11 @@ class CharacterCommentListFragment : BaseFragment + + msg?.let { showToast(it) } + } + + viewModel.totalCommentCount.observe(viewLifecycleOwner) { count -> + binding.tvCommentCount.text = "$count" + } + + viewModel.commentList.observe(viewLifecycleOwner) { items -> + if (viewModel.page - 1 == 1) { + adapter.items.clear() + binding.rvComment.scrollToPosition(0) + } + adapter.items.addAll(items) + adapter.notifyDataSetChanged() + } } private fun hideKeyboard() { imm.hideSoftInputFromWindow(view?.windowToken, 0) } - private var cursor: Long? = null - private var isLoading = false - - @SuppressLint("NotifyDataSetChanged") - private fun resetAndLoad() { - cursor = null - adapter.items.clear() - adapter.notifyDataSetChanged() - loadMore() - } - - 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 - adapter.items.addAll(items) - adapter.notifyItemRangeInserted(start, items.size) - binding.tvCommentCount.text = data?.totalCount?.toString() ?: "0" - 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 { private const val EXTRA_CHARACTER_ID = "extra_character_id" diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListViewModel.kt new file mode 100644 index 00000000..5a50d24f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListViewModel.kt @@ -0,0 +1,197 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.base.BaseViewModel +import kr.co.vividnext.sodalive.common.SharedPreferenceManager + +class CharacterCommentListViewModel( + private val repository: CharacterCommentRepository +) : BaseViewModel() { + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _commentList = MutableLiveData>() + val commentList: LiveData> + get() = _commentList + + private val _totalCommentCount = MutableLiveData(0) + val totalCommentCount: LiveData + get() = _totalCommentCount + + // 페이지네이션 상태 (cursor 기반이지만 UI에선 1페이지 초기화 판단을 위해 page 인덱스 유지) + var page: Int = 1 + private set + private var isLast: Boolean = false + private val size: Int = 20 + private var cursor: Long? = null + + fun reset(characterId: Long) { + page = 1 + isLast = false + cursor = null + getCommentList(characterId) + } + + fun getCommentList(characterId: Long, onFailure: (() -> Unit)? = null) { + // 로딩 중이면 차단 + if (_isLoading.value == true) return + // 이슈 요구사항: 초기 1회 로드 허용, 이후엔 cursor != null일 때만 추가 로드 + if (page > 1 && cursor == null) return + // 이미 마지막이면 차단 (보조 안전장치) + if (isLast) return + + _isLoading.value = true + + val token = "Bearer ${SharedPreferenceManager.token}" + compositeDisposable.add( + repository.listComments( + characterId = characterId, + limit = size, + cursor = cursor, + token = token + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ resp -> + _isLoading.value = false + if (resp.success && resp.data != null) { + // total count 업데이트 + _totalCommentCount.postValue(resp.data.totalCount) + + // 다음 페이지 커서 및 마지막 여부 갱신 + val nextCursor = resp.data.cursor + cursor = nextCursor + isLast = (nextCursor == null) + + // 페이지 인덱스 증가 (UI에서 초기화 판단용) + page += 1 + + val items = resp.data.comments + // 응답 아이템 전달 (비어있어도 전달) — UI는 addAll 처리 + _commentList.postValue(items) + } else { + val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + _toastLiveData.postValue(message) + onFailure?.invoke() + } + }, { e -> + _isLoading.value = false + Logger.e(e, "Character comments load failed") + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + onFailure?.invoke() + }) + ) + } + + fun createComment(characterId: Long, comment: String) { + if (comment.isBlank()) { + _toastLiveData.postValue("내용을 입력하세요") + return + } + if (_isLoading.value == true) return + _isLoading.value = true + + val token = "Bearer ${SharedPreferenceManager.token}" + compositeDisposable.add( + repository.createComment( + characterId = characterId, + comment = comment, + token = token + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ resp -> + _isLoading.value = false + if (resp.success) { + // 목록 초기화 후 재조회 + page = 1 + isLast = false + cursor = null + getCommentList(characterId) + } else { + val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + _toastLiveData.postValue(message) + } + }, { e -> + _isLoading.value = false + Logger.e(e, "Character comment create failed") + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + }) + ) + } + + fun deleteComment(characterId: Long, commentId: Long) { + if (_isLoading.value == true) return + _isLoading.value = true + val token = "Bearer ${SharedPreferenceManager.token}" + compositeDisposable.add( + repository.deleteComment( + characterId = characterId, + commentId = commentId, + token = token + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ resp -> + _isLoading.value = false + if (resp.success) { + // 간단하게 전체를 새로고침 + page = 1 + isLast = false + cursor = null + getCommentList(characterId) + } else { + val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + _toastLiveData.postValue(message) + } + }, { e -> + _isLoading.value = false + Logger.e(e, "Character comment delete failed") + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + }) + ) + } + + fun reportComment(characterId: Long, commentId: Long, reason: String) { + if (reason.isBlank()) { + _toastLiveData.postValue("신고 사유를 입력하세요") + return + } + if (_isLoading.value == true) return + _isLoading.value = true + val token = "Bearer ${SharedPreferenceManager.token}" + compositeDisposable.add( + repository.reportComment( + characterId = characterId, + commentId = commentId, + reason = reason, + token = token + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ resp -> + _isLoading.value = false + if (resp.success) { + _toastLiveData.postValue("신고가 접수되었습니다.") + } else { + val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + _toastLiveData.postValue(message) + } + }, { e -> + _isLoading.value = false + Logger.e(e, "Character comment report failed") + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + }) + ) + } +} 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 231ef582..9be10063 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 @@ -359,6 +359,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { CharacterTabViewModel(get()) } viewModel { CharacterDetailViewModel(get()) } viewModel { TalkTabViewModel(get()) } + viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListViewModel(get()) } } private val repositoryModule = module {