feat(creator-channel): 커뮤니티 상세 상태 관리를 추가한다
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user