refactor(chat/character): 댓글 리스트 화면에 ViewModel 도입 및 Fragment-Repository 직접 의존 제거

CharacterCommentListViewModel을 추가하여 댓글 조회/등록/삭제/신고 및 페이지네이션 로직을 ViewModel로 이전.
Fragment는 UI 업데이트와 사용자 입력 처리에 집중하도록 리팩토링.
Koin DI에 ViewModel 등록.
This commit is contained in:
2025-08-20 16:22:34 +09:00
parent fdc9ba80e0
commit ccd88dad47
3 changed files with 236 additions and 139 deletions

View File

@@ -8,14 +8,12 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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
@@ -28,7 +26,7 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
FragmentCharacterCommentListBinding::inflate FragmentCharacterCommentListBinding::inflate
) { ) {
private val repository: CharacterCommentRepository by inject() private val viewModel: CharacterCommentListViewModel by inject()
private lateinit var imm: InputMethodManager private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog private lateinit var loadingDialog: LoadingDialog
@@ -54,7 +52,7 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
setupView() setupView()
bindData() bindData()
// 초기 로드 // 초기 로드
resetAndLoad() viewModel.reset(characterId)
} }
private fun hideDialog() { private fun hideDialog() {
@@ -75,31 +73,8 @@ 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
val token = "Bearer ${SharedPreferenceManager.token}" viewModel.createComment(characterId, comment)
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("") 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( adapter = CharacterCommentsAdapter(
@@ -109,74 +84,20 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
onReport = { onReport = {
val reportSheet = CharacterCommentReportBottomSheet.newInstance() val reportSheet = CharacterCommentReportBottomSheet.newInstance()
reportSheet.onSubmit = { reason -> reportSheet.onSubmit = { reason ->
val token = "Bearer ${SharedPreferenceManager.token}" viewModel.reportComment(characterId, item.commentId, reason)
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(parentFragmentManager, "comment_report") reportSheet.show(parentFragmentManager, "comment_report")
} }
onDelete = { onDelete = {
// 삭제 확인 팝업 // 삭제 확인 팝업
androidx.appcompat.app.AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setTitle(getString(R.string.confirm_delete_title)) .setTitle(getString(R.string.confirm_delete_title))
.setMessage(getString(R.string.confirm_delete_message)) .setMessage(getString(R.string.confirm_delete_message))
.setPositiveButton(getString(R.string.confirm)) { _, _ -> .setPositiveButton(getString(R.string.confirm)) { _, _ ->
val token = "Bearer ${SharedPreferenceManager.token}" viewModel.deleteComment(
loadingDialog.show(screenWidth)
val d = repository.deleteComment(
characterId = characterId, characterId = characterId,
commentId = item.commentId, commentId = item.commentId
token = token
) )
.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) .setNegativeButton(getString(R.string.cancel), null)
.show() .show()
@@ -229,13 +150,11 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
val lastVisible = val lastVisible = (recyclerView.layoutManager as LinearLayoutManager)
(recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition() .findLastCompletelyVisibleItemPosition()
val total = recyclerView.adapter?.itemCount ?: 0 val total = recyclerView.adapter?.itemCount ?: 0
// 초기 진입(아이템 없음) 또는 다음 페이지가 존재할 때(cursor != null)에만 로드 if (!recyclerView.canScrollVertically(1) && lastVisible == total - 1) {
val canLoadMore = adapter.items.isEmpty() || cursor != null viewModel.getCommentList(characterId)
if (canLoadMore && !recyclerView.canScrollVertically(1) && lastVisible == total - 1) {
loadMore()
} }
} }
}) })
@@ -245,57 +164,37 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun bindData() { private fun bindData() {
// total count 스텁: 어댑터 크기 사용 viewModel.isLoading.observe(viewLifecycleOwner) {
// 필요 시 ViewModel 도입 가능 if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(viewLifecycleOwner) { msg ->
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() { private fun hideKeyboard() {
imm.hideSoftInputFromWindow(view?.windowToken, 0) 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 { companion object {
private const val EXTRA_CHARACTER_ID = "extra_character_id" private const val EXTRA_CHARACTER_ID = "extra_character_id"

View File

@@ -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<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _commentList = MutableLiveData<List<CharacterCommentResponse>>()
val commentList: LiveData<List<CharacterCommentResponse>>
get() = _commentList
private val _totalCommentCount = MutableLiveData(0)
val totalCommentCount: LiveData<Int>
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("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
}

View File

@@ -359,6 +359,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { CharacterTabViewModel(get()) } viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) } viewModel { CharacterDetailViewModel(get()) }
viewModel { TalkTabViewModel(get()) } viewModel { TalkTabViewModel(get()) }
viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListViewModel(get()) }
} }
private val repositoryModule = module { private val repositoryModule = module {