feat(creator-channel): 커뮤니티 상세 상태 관리를 추가한다

This commit is contained in:
2026-07-09 00:53:06 +09:00
parent 9e524d626a
commit c04d603ac0
2 changed files with 951 additions and 0 deletions

View File

@@ -0,0 +1,447 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelCommunityDetailViewModel(
private val repository: CreatorChannelRepository,
private val legacyRepository: CreatorCommunityRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _detailStateLiveData = MutableLiveData<CreatorChannelCommunityDetailUiState>()
val detailStateLiveData: LiveData<CreatorChannelCommunityDetailUiState>
get() = _detailStateLiveData
private val _commentWrittenEventLiveData = MutableLiveData<Boolean>()
val commentWrittenEventLiveData: LiveData<Boolean>
get() = _commentWrittenEventLiveData
private val _postChangedEventLiveData = MutableLiveData<Boolean>()
val postChangedEventLiveData: LiveData<Boolean>
get() = _postChangedEventLiveData
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
get() = _toastLiveData
private var postId: Long = 0L
private var detailRequestGeneration = 0
private var commentRequestGeneration = 0
private var isLoadingComments = false
private var isTogglingLike = false
private var isSendingComment = false
private var isModifyingComment = false
fun loadDetail(postId: Long) {
if (postId <= 0) return
this.postId = postId
val generation = ++detailRequestGeneration
commentRequestGeneration++
isLoadingComments = false
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Loading
compositeDisposable.add(
repository.getCommunityPostDetail(postId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
if (generation == detailRequestGeneration) {
handleDetailResponse(response)
}
},
{
if (generation == detailRequestGeneration) {
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(it.message)
}
}
)
)
}
fun loadMoreComments() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.isCommentAvailable || !content.hasNextComment || isLoadingComments) return
loadComments(page = content.commentPage + 1, append = true)
}
fun toggleLike() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (isTogglingLike) return
val previousPost = content.post
val nextPost = previousPost.copy(
isLiked = !previousPost.isLiked,
likeCount = (previousPost.likeCount + if (previousPost.isLiked) -1 else 1).coerceAtLeast(0)
)
isTogglingLike = true
_detailStateLiveData.value = content.copy(post = nextPost)
compositeDisposable.add(
legacyRepository.communityPostLike(postId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isTogglingLike = false
if (response.success) {
_postChangedEventLiveData.value = true
} else {
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(post = previousPost)
}
},
{
isTogglingLike = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(post = previousPost)
}
)
)
}
fun updateCommentInput(input: String) {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.isCommentAvailable) return
_detailStateLiveData.value = content.copy(commentInput = input)
}
fun submitComment() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
val comment = content.commentInput.trim()
if (!content.isCommentAvailable || comment.isBlank() || isSendingComment) return
isSendingComment = true
_detailStateLiveData.value = content.copy(isSendingComment = true)
compositeDisposable.add(
legacyRepository.registerComment(
postId = postId,
comment = comment,
parentId = null,
isSecret = false,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isSendingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
if (response.success) {
_commentWrittenEventLiveData.value = true
_detailStateLiveData.value = current.copy(commentInput = "", isSendingComment = false)
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
},
{
isSendingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
showMutationFailureToast()
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
)
)
}
fun consumeCommentWrittenEvent() {
_commentWrittenEventLiveData.value = false
}
fun consumePostChangedEvent() {
_postChangedEventLiveData.value = false
}
fun modifyComment(commentId: Long, comment: String) {
val trimmedComment = comment.trim()
if (commentId <= 0 || isModifyingComment) return
if (trimmedComment.isBlank()) {
showBlankCommentToast()
return
}
modifyComment(ModifyCommentRequest(commentId = commentId, comment = trimmedComment))
}
fun deleteComment(commentId: Long) {
if (commentId <= 0 || isModifyingComment) return
modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false))
}
private fun handleDetailResponse(response: ApiResponse<CreatorChannelCommunityPostDetailResponse>) {
val data = response.data
if (!response.success || data == null) {
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(response.message)
return
}
val isCreatorOwner = data.creatorId == SharedPreferenceManager.userId
val comments = if (data.isCommentAvailable) {
data.comments.comments.map { it.toUiModel(isCreatorOwner) }
} else {
emptyList()
}
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Content(
post = data.toUiModel(),
isCommentAvailable = data.isCommentAvailable,
isCommentInputVisible = data.isCommentAvailable,
comments = comments,
commentPage = data.comments.page,
hasNextComment = data.isCommentAvailable && data.comments.hasNext
)
}
private fun loadComments(page: Int, append: Boolean) {
if (postId <= 0 || isLoadingComments && append) return
val generation = if (append) {
commentRequestGeneration
} else {
++commentRequestGeneration
}
isLoadingComments = true
val beforeRequest = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
if (beforeRequest != null) {
_detailStateLiveData.value = beforeRequest.copy(isLoadingComments = true)
}
compositeDisposable.add(
repository.getCommunityPostComments(postId, page, DEFAULT_PAGE_SIZE, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response -> handleCommentsResponse(response, append, generation) },
{
if (generation != commentRequestGeneration) return@subscribe
isLoadingComments = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(isLoadingComments = false)
}
)
)
}
private fun handleCommentsResponse(
response: ApiResponse<CreatorChannelCommunityCommentsResponse>,
append: Boolean,
generation: Int
) {
if (generation != commentRequestGeneration) return
isLoadingComments = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
val data = response.data
if (!response.success || data == null) {
_detailStateLiveData.value = current.copy(isLoadingComments = false)
return
}
val isCreatorOwner = current.post.creatorId == SharedPreferenceManager.userId
val nextComments = data.comments.map { it.toUiModel(isCreatorOwner) }
_detailStateLiveData.value = current.copy(
post = current.post.copy(commentCount = data.commentCount),
comments = if (append) current.comments + nextComments else nextComments,
commentPage = data.page,
hasNextComment = data.hasNext,
isLoadingComments = false
)
}
private fun modifyComment(request: ModifyCommentRequest) {
if (postId <= 0) return
isModifyingComment = true
compositeDisposable.add(
legacyRepository.modifyComment(request, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingComment = false
if (response.success) {
_postChangedEventLiveData.value = true
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
}
},
{
isModifyingComment = false
showMutationFailureToast()
}
)
)
}
private fun showMutationFailureToast(message: String? = null) {
_toastLiveData.value = CreatorChannelEvent(
if (message.isNullOrBlank()) {
ToastMessage(resId = R.string.common_error_unknown)
} else {
ToastMessage(message = message)
}
)
}
private fun showBlankCommentToast() {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.screen_creator_community_write_content_hint)
)
}
private fun CreatorChannelCommunityPostDetailResponse.toUiModel() = CreatorChannelCommunityPostDetailUiModel(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfileUrl,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
content = content,
imageUrl = imageUrl.takeUnless { isLocked() },
audioUrl = audioUrl.takeUnless { isLocked() },
price = price,
existOrdered = existOrdered,
likeCount = likeCount,
commentCount = commentCount,
isLiked = isLiked,
isPinned = isPinned,
isLocked = isLocked(),
showPaywall = isLocked()
)
private fun CreatorChannelCommunityPostDetailResponse.isLocked(): Boolean {
return price > 0 && !existOrdered && creatorId != SharedPreferenceManager.userId
}
private fun CreatorChannelCommunityCommentResponse.toUiModel(
isCreatorOwner: Boolean
) = CreatorChannelCommunityCommentUiModel(
commentId = commentId,
memberId = writerId,
memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content,
likeCount = 0,
replyCount = if (latestReply != null) 1 else 0,
isLiked = false,
isMine = writerId == SharedPreferenceManager.userId,
isCreatorOwner = isCreatorOwner,
latestReply = latestReply?.toUiModel(parentCommentId = commentId, isCreatorOwner = isCreatorOwner)
)
private fun CreatorChannelCommunityReplyResponse.toUiModel(
parentCommentId: Long,
isCreatorOwner: Boolean
) = CreatorChannelCommunityReplyUiModel(
replyId = commentId,
commentId = parentCommentId,
memberId = writerId,
memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content,
likeCount = 0,
isLiked = false,
isMine = writerId == SharedPreferenceManager.userId,
isCreatorOwner = isCreatorOwner
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelCommunityDetailUiState {
data object Loading : CreatorChannelCommunityDetailUiState
data class Error(val message: String?) : CreatorChannelCommunityDetailUiState
data class Content(
val post: CreatorChannelCommunityPostDetailUiModel,
val isCommentAvailable: Boolean,
val isCommentInputVisible: Boolean,
val comments: List<CreatorChannelCommunityCommentUiModel>,
val commentPage: Int,
val hasNextComment: Boolean,
val isLoadingComments: Boolean = false,
val commentInput: String = "",
val isSendingComment: Boolean = false
) : CreatorChannelCommunityDetailUiState
}
data class CreatorChannelCommunityPostDetailUiModel(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String?,
val createdAtText: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
val isLiked: Boolean,
val isPinned: Boolean,
val isLocked: Boolean,
val showPaywall: Boolean
)
data class CreatorChannelCommunityCommentUiModel(
val commentId: Long,
val memberId: Long,
val memberNickname: String,
val memberProfileUrl: String?,
val createdAtUtc: String,
val createdAtText: String,
val comment: String,
val likeCount: Int,
val replyCount: Int,
val isLiked: Boolean,
val isMine: Boolean,
val isCreatorOwner: Boolean,
val latestReply: CreatorChannelCommunityReplyUiModel?
)
data class CreatorChannelCommunityReplyUiModel(
val replyId: Long,
val commentId: Long,
val memberId: Long,
val memberNickname: String,
val memberProfileUrl: String?,
val createdAtUtc: String,
val createdAtText: String,
val comment: String,
val likeCount: Int,
val isLiked: Boolean,
val isMine: Boolean,
val isCreatorOwner: Boolean
)