feat(creator-channel): 답글 수정 입력 모드를 구현한다

This commit is contained in:
2026-07-09 01:28:35 +09:00
parent a325a1bbe9
commit 1f1069b05d
3 changed files with 221 additions and 74 deletions

View File

@@ -3,11 +3,9 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.text.InputType
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.widget.EditText import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -15,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityReplyBinding import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityReplyBinding
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.ui.CreatorChannelCommunityReplyAdapter import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.ui.CreatorChannelCommunityReplyAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
@@ -26,10 +25,11 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
private val viewModel: CreatorChannelCommunityReplyViewModel by viewModel() private val viewModel: CreatorChannelCommunityReplyViewModel by viewModel()
private val replyAdapter = CreatorChannelCommunityReplyAdapter( private val replyAdapter = CreatorChannelCommunityReplyAdapter(
onModifyClick = { reply -> showModifyReplyDialog(reply.replyId, reply.comment) }, onModifyClick = { reply -> viewModel.startReplyEdit(reply.replyId, reply.comment) },
onDeleteClick = { reply -> showDeleteReplyDialog(reply.replyId) } onDeleteClick = { reply -> showDeleteReplyDialog(reply.replyId) }
) )
private var postId: Long = 0L private var postId: Long = 0L
private var renderedEditingTarget: CreatorChannelCommunityReplyEditTarget? = null
override fun setupView() { override fun setupView() {
postId = intent.getLongExtra(EXTRA_POST_ID, 0L) postId = intent.getLongExtra(EXTRA_POST_ID, 0L)
@@ -57,17 +57,26 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
viewModel.updateReplyInput(editable?.toString().orEmpty()) viewModel.updateReplyInput(editable?.toString().orEmpty())
} }
binding.btnCreatorChannelCommunityReplySend.setOnClickListener { viewModel.submitReply() } binding.btnCreatorChannelCommunityReplySend.setOnClickListener { viewModel.submitReply() }
binding.btnCreatorChannelCommunityReplyEditCancel.setOnClickListener { viewModel.cancelReplyEdit() }
viewModel.replyStateLiveData.observe(this) { state -> render(state) } viewModel.replyStateLiveData.observe(this) { state -> render(state) }
viewModel.replyWrittenEventLiveData.observe(this) { written ->
if (written == true) {
hideReplyKeyboard()
viewModel.consumeReplyWrittenEvent()
}
}
viewModel.replyChangedEventLiveData.observe(this) { changed -> viewModel.replyChangedEventLiveData.observe(this) { changed ->
if (changed == true) { if (changed == true) {
setResult(Activity.RESULT_OK) setResult(Activity.RESULT_OK)
hideReplyKeyboard()
viewModel.consumeReplyChangedEvent() viewModel.consumeReplyChangedEvent()
} }
} }
viewModel.parentCommentChangedEventLiveData.observe(this) { changed -> viewModel.parentCommentChangedEventLiveData.observe(this) { changed ->
if (changed == true) { if (changed == true) {
setResult(Activity.RESULT_OK) setResult(Activity.RESULT_OK)
hideReplyKeyboard()
viewModel.consumeParentCommentChangedEvent() viewModel.consumeParentCommentChangedEvent()
} }
} }
@@ -98,7 +107,7 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
layoutCreatorChannelCommunityReplyParent, layoutCreatorChannelCommunityReplyParent,
content.parentComment, content.parentComment,
onReplyClick = {}, onReplyClick = {},
onModifyClick = { comment -> showModifyParentCommentDialog(comment.comment) }, onModifyClick = { comment -> viewModel.startParentCommentEdit(comment.comment) },
onDeleteClick = { showDeleteParentCommentDialog() } onDeleteClick = { showDeleteParentCommentDialog() }
) )
layoutCreatorChannelCommunityReplyParent.layoutCreatorChannelCommunityCommentLatestReply.isVisible = false layoutCreatorChannelCommunityReplyParent.layoutCreatorChannelCommunityCommentLatestReply.isVisible = false
@@ -107,69 +116,36 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
etCreatorChannelCommunityReply.setText(content.replyInput) etCreatorChannelCommunityReply.setText(content.replyInput)
etCreatorChannelCommunityReply.setSelection(content.replyInput.length) etCreatorChannelCommunityReply.setSelection(content.replyInput.length)
} }
btnCreatorChannelCommunityReplyEditCancel.isVisible = content.editingTarget != null
if (content.editingTarget != null && content.editingTarget != renderedEditingTarget) {
showReplyKeyboard()
}
renderedEditingTarget = content.editingTarget
updateSendButton(content.replyInput.trim().isNotEmpty() && !content.isSendingReply) updateSendButton(content.replyInput.trim().isNotEmpty() && !content.isSendingReply)
} }
private fun showModifyReplyDialog(replyId: Long, comment: String) {
val editText = EditText(this).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
minLines = 3
setText(comment)
setSelection(comment.length)
}
AlertDialog.Builder(this)
.setTitle(R.string.menu_modify_action)
.setView(editText)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.confirm) { _, _ ->
val modifiedComment = editText.text?.toString().orEmpty()
if (modifiedComment.trim() == comment.trim()) {
Toast.makeText(this, R.string.audio_content_comment_no_change, Toast.LENGTH_SHORT).show()
} else {
viewModel.modifyReply(replyId, modifiedComment)
}
}
.show()
}
private fun showModifyParentCommentDialog(comment: String) {
val editText = EditText(this).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
minLines = 3
setText(comment)
setSelection(comment.length)
}
AlertDialog.Builder(this)
.setTitle(R.string.menu_modify_action)
.setView(editText)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.confirm) { _, _ ->
val modifiedComment = editText.text?.toString().orEmpty()
if (modifiedComment.trim() == comment.trim()) {
Toast.makeText(this, R.string.audio_content_comment_no_change, Toast.LENGTH_SHORT).show()
} else {
viewModel.modifyParentComment(modifiedComment)
}
}
.show()
}
private fun showDeleteParentCommentDialog() { private fun showDeleteParentCommentDialog() {
AlertDialog.Builder(this) V2ModalDialog(
.setTitle(R.string.confirm_delete_title) activity = this,
.setMessage(R.string.confirm_delete_message) layoutInflater = layoutInflater,
.setNegativeButton(R.string.cancel, null) title = getString(R.string.confirm_delete_title),
.setPositiveButton(R.string.confirm) { _, _ -> viewModel.deleteParentComment() } desc = getString(R.string.confirm_delete_message),
.show() confirmButtonTitle = getString(R.string.confirm),
confirmButtonClick = { viewModel.deleteParentComment() },
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
} }
private fun showDeleteReplyDialog(replyId: Long) { private fun showDeleteReplyDialog(replyId: Long) {
AlertDialog.Builder(this) V2ModalDialog(
.setTitle(R.string.confirm_delete_title) activity = this,
.setMessage(R.string.confirm_delete_message) layoutInflater = layoutInflater,
.setNegativeButton(R.string.cancel, null) title = getString(R.string.confirm_delete_title),
.setPositiveButton(R.string.confirm) { _, _ -> viewModel.deleteReply(replyId) } desc = getString(R.string.confirm_delete_message),
.show() confirmButtonTitle = getString(R.string.confirm),
confirmButtonClick = { viewModel.deleteReply(replyId) },
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
} }
private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelCommunityReplySend) { private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelCommunityReplySend) {
@@ -184,6 +160,22 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
setImageResource(if (enabled) R.drawable.ic_new_arrow_up_white else R.drawable.ic_new_arrow_up_gray) setImageResource(if (enabled) R.drawable.ic_new_arrow_up_white else R.drawable.ic_new_arrow_up_gray)
} }
private fun showReplyKeyboard() {
val editText = binding.etCreatorChannelCommunityReply
editText.requestFocus()
editText.post {
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideReplyKeyboard() {
val editText = binding.etCreatorChannelCommunityReply
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(editText.windowToken, 0)
editText.clearFocus()
}
private fun parentCommentFromIntent(): CreatorChannelCommunityCommentUiModel { private fun parentCommentFromIntent(): CreatorChannelCommunityCommentUiModel {
return CreatorChannelCommunityCommentUiModel( return CreatorChannelCommunityCommentUiModel(
commentId = intent.getLongExtra(EXTRA_COMMENT_ID, 0L), commentId = intent.getLongExtra(EXTRA_COMMENT_ID, 0L),

View File

@@ -90,6 +90,11 @@ class CreatorChannelCommunityReplyViewModel(
fun submitReply() { fun submitReply() {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
val comment = content.replyInput.trim() val comment = content.replyInput.trim()
val editingTarget = content.editingTarget
if (editingTarget != null) {
submitReplyEdit(content, editingTarget, comment)
return
}
val commentId = content.parentComment.commentId val commentId = content.parentComment.commentId
if (postId <= 0 || commentId <= 0 || comment.isBlank() || isSendingReply) return if (postId <= 0 || commentId <= 0 || comment.isBlank() || isSendingReply) return
@@ -134,6 +139,31 @@ class CreatorChannelCommunityReplyViewModel(
_parentCommentDeletedEventLiveData.value = false _parentCommentDeletedEventLiveData.value = false
} }
fun startReplyEdit(replyId: Long, comment: String) {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
if (replyId <= 0) return
_replyStateLiveData.value = content.copy(
replyInput = comment,
editingTarget = CreatorChannelCommunityReplyEditTarget.Reply(replyId, comment)
)
}
fun startParentCommentEdit(comment: String) {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
if (content.parentComment.commentId <= 0) return
_replyStateLiveData.value = content.copy(
replyInput = comment,
editingTarget = CreatorChannelCommunityReplyEditTarget.ParentComment(comment)
)
}
fun cancelReplyEdit() {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
_replyStateLiveData.value = content.copy(replyInput = "", editingTarget = null)
}
fun modifyReply(replyId: Long, comment: String) { fun modifyReply(replyId: Long, comment: String) {
val trimmedComment = comment.trim() val trimmedComment = comment.trim()
if (replyId <= 0 || isModifyingReply) return if (replyId <= 0 || isModifyingReply) return
@@ -170,6 +200,112 @@ class CreatorChannelCommunityReplyViewModel(
deleteParentComment(parentComment.commentId) deleteParentComment(parentComment.commentId)
} }
private fun submitReplyEdit(
content: CreatorChannelCommunityReplyUiState.Content,
target: CreatorChannelCommunityReplyEditTarget,
comment: String
) {
if (isModifyingReply) return
if (comment.isBlank()) {
showBlankCommentToast()
return
}
if (comment == target.originalComment.trim()) {
showNoChangeToast()
return
}
when (target) {
is CreatorChannelCommunityReplyEditTarget.ParentComment -> modifyParentCommentFromInput(content, comment)
is CreatorChannelCommunityReplyEditTarget.Reply -> modifyReplyFromInput(content, target.replyId, comment)
}
}
private fun modifyReplyFromInput(
content: CreatorChannelCommunityReplyUiState.Content,
replyId: Long,
comment: String
) {
isModifyingReply = true
_replyStateLiveData.value = content.copy(isSendingReply = true)
compositeDisposable.add(
legacyRepository.modifyComment(ModifyCommentRequest(commentId = replyId, comment = comment), authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
if (response.success) {
_replyChangedEventLiveData.value = true
_replyStateLiveData.value = current.copy(
replyInput = "",
isSendingReply = false,
editingTarget = null
)
loadReplyPage(page = FIRST_PAGE, append = false)
} else {
showMutationFailureToast(response.message)
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
},
{
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
showMutationFailureToast()
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
)
)
}
private fun modifyParentCommentFromInput(
content: CreatorChannelCommunityReplyUiState.Content,
comment: String
) {
val parentComment = parentComment ?: return
isModifyingReply = true
_replyStateLiveData.value = content.copy(isSendingReply = true)
compositeDisposable.add(
legacyRepository.modifyComment(
ModifyCommentRequest(commentId = parentComment.commentId, comment = comment),
authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
if (response.success) {
val updatedParent = parentComment.copy(comment = comment)
this.parentComment = updatedParent
_replyStateLiveData.value = current.copy(
parentComment = updatedParent,
replyInput = "",
isSendingReply = false,
editingTarget = null
)
_parentCommentChangedEventLiveData.value = true
} else {
showMutationFailureToast(response.message)
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
},
{
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
showMutationFailureToast()
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
)
)
}
private fun modifyParentComment(parentComment: CreatorChannelCommunityCommentUiModel, comment: String) { private fun modifyParentComment(parentComment: CreatorChannelCommunityCommentUiModel, comment: String) {
isModifyingReply = true isModifyingReply = true
compositeDisposable.add( compositeDisposable.add(
@@ -337,6 +473,12 @@ class CreatorChannelCommunityReplyViewModel(
) )
} }
private fun showNoChangeToast() {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.audio_content_comment_no_change)
)
}
private fun CreatorChannelCommunityReplyResponse.toUiModel() = CreatorChannelCommunityReplyUiModel( private fun CreatorChannelCommunityReplyResponse.toUiModel() = CreatorChannelCommunityReplyUiModel(
replyId = commentId, replyId = commentId,
commentId = parentComment?.commentId ?: 0L, commentId = parentComment?.commentId ?: 0L,
@@ -360,6 +502,19 @@ class CreatorChannelCommunityReplyViewModel(
} }
} }
sealed interface CreatorChannelCommunityReplyEditTarget {
val originalComment: String
data class ParentComment(
override val originalComment: String
) : CreatorChannelCommunityReplyEditTarget
data class Reply(
val replyId: Long,
override val originalComment: String
) : CreatorChannelCommunityReplyEditTarget
}
sealed interface CreatorChannelCommunityReplyUiState { sealed interface CreatorChannelCommunityReplyUiState {
data class Content( data class Content(
val parentComment: CreatorChannelCommunityCommentUiModel, val parentComment: CreatorChannelCommunityCommentUiModel,
@@ -368,6 +523,7 @@ sealed interface CreatorChannelCommunityReplyUiState {
val hasNextReply: Boolean, val hasNextReply: Boolean,
val isLoadingReplies: Boolean = false, val isLoadingReplies: Boolean = false,
val replyInput: String = "", val replyInput: String = "",
val isSendingReply: Boolean = false val isSendingReply: Boolean = false,
val editingTarget: CreatorChannelCommunityReplyEditTarget? = null
) : CreatorChannelCommunityReplyUiState ) : CreatorChannelCommunityReplyUiState
} }

View File

@@ -25,18 +25,6 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_creator_channel_community_reply_title"
style="@style/Typography.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/creator_channel_community_reply_title"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <LinearLayout
@@ -103,5 +91,16 @@
android:contentDescription="@string/action_send" android:contentDescription="@string/action_send"
android:enabled="false" android:enabled="false"
android:src="@drawable/ic_new_arrow_up_gray" /> android:src="@drawable/ic_new_arrow_up_gray" />
<ImageButton
android:id="@+id/btn_creator_channel_community_reply_edit_cancel"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@android:color/transparent"
android:contentDescription="@string/cancel"
android:src="@drawable/ic_x_white"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>