From d10373d265a26f7ba4aa827847e492440363394c Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 9 Jul 2026 00:53:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelCommunityDetailActivity.kt | 233 ++++++++++++++++++ .../CreatorChannelCommunityCommentAdapter.kt | 116 +++++++++ 2 files changed, 349 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/ui/CreatorChannelCommunityCommentAdapter.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailActivity.kt new file mode 100644 index 00000000..a175c3f7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailActivity.kt @@ -0,0 +1,233 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.detail + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.InputType +import android.text.method.ScrollingMovementMethod +import android.widget.EditText +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.recyclerview.widget.LinearLayoutManager +import coil.transform.CircleCropTransformation +import com.bumptech.glide.Glide +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.base.BaseActivity +import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding +import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityContentItem +import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager +import kr.co.vividnext.sodalive.extensions.loadUrl +import kr.co.vividnext.sodalive.extensions.moneyFormat +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyActivity +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter +import org.koin.androidx.viewmodel.ext.android.viewModel + +class CreatorChannelCommunityDetailActivity : BaseActivity( + ActivityCreatorChannelCommunityDetailBinding::inflate +) { + + private val viewModel: CreatorChannelCommunityDetailViewModel by viewModel() + private val replyLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK && postId > 0L) { + setResult(Activity.RESULT_OK) + viewModel.loadDetail(postId) + } + } + private val commentAdapter = CreatorChannelCommunityCommentAdapter( + onReplyClick = { comment -> replyLauncher.launch(CreatorChannelCommunityReplyActivity.newIntent(this, postId, comment)) }, + onModifyClick = { comment -> showModifyCommentDialog(comment.commentId, comment.comment) }, + onDeleteClick = { comment -> showDeleteCommentDialog(comment.commentId) } + ) + private lateinit var mediaPlayerManager: CreatorCommunityMediaPlayerManager + private var postId: Long = 0L + + override fun setupView() { + mediaPlayerManager = CreatorCommunityMediaPlayerManager(this) { } + postId = intent.getLongExtra(EXTRA_POST_ID, 0L) + if (postId <= 0L) { + Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show() + finish() + return + } + + binding.btnCreatorChannelCommunityDetailBack.setOnClickListener { finish() } + binding.rvCreatorChannelCommunityDetailComments.layoutManager = LinearLayoutManager(this) + binding.rvCreatorChannelCommunityDetailComments.adapter = commentAdapter + binding.scrollCreatorChannelCommunityDetail.setOnScrollChangeListener { scrollView, _, scrollY, _, oldScrollY -> + val contentHeight = binding.scrollCreatorChannelCommunityDetail.getChildAt(0)?.measuredHeight ?: 0 + val isScrollingDown = scrollY > oldScrollY + val isAtBottom = scrollY >= contentHeight - scrollView.measuredHeight + if (isScrollingDown && isAtBottom) { + viewModel.loadMoreComments() + } + } + binding.etCreatorChannelCommunityDetailComment.movementMethod = ScrollingMovementMethod() + binding.etCreatorChannelCommunityDetailComment.doAfterTextChanged { editable -> + viewModel.updateCommentInput(editable?.toString().orEmpty()) + } + binding.btnCreatorChannelCommunityDetailSend.setOnClickListener { viewModel.submitComment() } + binding.ivCreatorChannelCommunityDetailHeart.setOnClickListener { viewModel.toggleLike() } + + viewModel.detailStateLiveData.observe(this) { state -> render(state) } + viewModel.commentWrittenEventLiveData.observe(this) { written -> + if (written == true) { + setResult(Activity.RESULT_OK) + viewModel.consumeCommentWrittenEvent() + } + } + viewModel.postChangedEventLiveData.observe(this) { changed -> + if (changed == true) { + setResult(Activity.RESULT_OK) + viewModel.consumePostChangedEvent() + } + } + viewModel.toastLiveData.observe(this) { event -> + event.consume()?.let { toast -> + val message = toast.message ?: toast.resId?.let(::getString) + message?.let(::showToast) + } + } + viewModel.loadDetail(postId) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onPause() { + mediaPlayerManager.pauseContent() + super.onPause() + } + + override fun onDestroy() { + mediaPlayerManager.stopContent() + super.onDestroy() + } + + private fun render(state: CreatorChannelCommunityDetailUiState) { + when (state) { + CreatorChannelCommunityDetailUiState.Loading -> Unit + is CreatorChannelCommunityDetailUiState.Error -> { + Toast.makeText( + this, + state.message ?: getString(R.string.creator_channel_community_error_message), + Toast.LENGTH_SHORT + ).show() + finish() + } + is CreatorChannelCommunityDetailUiState.Content -> renderContent(state) + } + } + + private fun renderContent(content: CreatorChannelCommunityDetailUiState.Content) = with(binding) { + val post = content.post + ivCreatorChannelCommunityDetailProfile.loadUrl(post.creatorProfileUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(CircleCropTransformation()) + } + tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname + tvCreatorChannelCommunityDetailTime.text = post.createdAtText + tvCreatorChannelCommunityDetailBody.text = post.content + tvCreatorChannelCommunityDetailLikeCount.text = post.likeCount.moneyFormat() + tvCreatorChannelCommunityDetailCommentCount.text = post.commentCount.moneyFormat() + tvCreatorChannelCommunityDetailCommentHeader.text = getString( + R.string.creator_channel_community_comment_header, + post.commentCount.moneyFormat() + ) + ivCreatorChannelCommunityDetailHeart.setImageResource( + if (post.isLiked) R.drawable.ic_feed_community_heart_fill else R.drawable.ic_feed_community_heart + ) + layoutCreatorChannelCommunityDetailPrice.isVisible = post.isLocked + tvCreatorChannelCommunityDetailPrice.text = post.price.moneyFormat() + tvCreatorChannelCommunityDetailPurchased.isVisible = post.existOrdered && post.price > 0 + layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall + layoutCreatorChannelCommunityDetailPaywall.isVisible = post.showPaywall + tvCreatorChannelCommunityDetailPaywallPrice.text = post.price.moneyFormat() + if (post.imageUrl != null) { + Glide.with(ivCreatorChannelCommunityDetailImage) + .load(post.imageUrl) + .placeholder(R.drawable.ic_place_holder) + .into(ivCreatorChannelCommunityDetailImage) + } else { + Glide.with(ivCreatorChannelCommunityDetailImage).clear(ivCreatorChannelCommunityDetailImage) + } + tvCreatorChannelCommunityDetailAudio.isVisible = post.audioUrl != null + tvCreatorChannelCommunityDetailAudio.text = getString(R.string.creator_channel_tab_audio) + tvCreatorChannelCommunityDetailAudio.setOnClickListener { + toggleAudio(post) + } + rvCreatorChannelCommunityDetailComments.isVisible = content.isCommentAvailable + tvCreatorChannelCommunityDetailCommentHeader.isVisible = content.isCommentAvailable + layoutCreatorChannelCommunityDetailInputBar.isVisible = content.isCommentInputVisible + ivCreatorChannelCommunityDetailComment.isVisible = content.isCommentAvailable + tvCreatorChannelCommunityDetailCommentCount.isVisible = content.isCommentAvailable + commentAdapter.submitItems(content.comments) + if (etCreatorChannelCommunityDetailComment.text.toString() != content.commentInput) { + etCreatorChannelCommunityDetailComment.setText(content.commentInput) + etCreatorChannelCommunityDetailComment.setSelection(content.commentInput.length) + } + updateSendButton(content.commentInput.trim().isNotEmpty() && !content.isSendingComment) + } + + private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelCommunityDetailSend) { + isEnabled = enabled + setBackgroundResource( + if (enabled) { + R.drawable.bg_creator_channel_community_send_enabled + } else { + R.drawable.bg_creator_channel_community_send_disabled + } + ) + setImageResource(if (enabled) R.drawable.ic_new_arrow_up_white else R.drawable.ic_new_arrow_up_gray) + } + + private fun toggleAudio(post: CreatorChannelCommunityPostDetailUiModel) { + val audioUrl = post.audioUrl ?: return + mediaPlayerManager.toggleContent(CreatorCommunityContentItem(post.postId, audioUrl)) + } + + private fun showModifyCommentDialog(commentId: 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.modifyComment(commentId, modifiedComment) + } + } + .show() + } + + private fun showDeleteCommentDialog(commentId: Long) { + AlertDialog.Builder(this) + .setTitle(R.string.confirm_delete_title) + .setMessage(R.string.confirm_delete_message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.confirm) { _, _ -> viewModel.deleteComment(commentId) } + .show() + } + + companion object { + private const val EXTRA_POST_ID = "extra_post_id" + + fun newIntent(context: Context, postId: Long): Intent { + return Intent(context, CreatorChannelCommunityDetailActivity::class.java) + .putExtra(EXTRA_POST_ID, postId) + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/ui/CreatorChannelCommunityCommentAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/ui/CreatorChannelCommunityCommentAdapter.kt new file mode 100644 index 00000000..a5afed69 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/ui/CreatorChannelCommunityCommentAdapter.kt @@ -0,0 +1,116 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import coil.transform.CircleCropTransformation +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityCommentBinding +import kr.co.vividnext.sodalive.extensions.loadUrl +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel + +class CreatorChannelCommunityCommentAdapter( + private val onReplyClick: (CreatorChannelCommunityCommentUiModel) -> Unit, + private val onModifyClick: (CreatorChannelCommunityCommentUiModel) -> Unit = {}, + private val onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit = {} +) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitItems(items: List) { + this.items = items + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ItemCreatorChannelCommunityCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false), + onReplyClick, + onModifyClick, + onDeleteClick + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + class ViewHolder( + private val binding: ItemCreatorChannelCommunityCommentBinding, + private val onReplyClick: (CreatorChannelCommunityCommentUiModel) -> Unit, + private val onModifyClick: (CreatorChannelCommunityCommentUiModel) -> Unit, + private val onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: CreatorChannelCommunityCommentUiModel) { + bindComment(binding, item, onReplyClick) + binding.ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore + binding.ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor -> + showOptionMenu(anchor, item, onModifyClick, onDeleteClick) + } + } + } + + companion object { + fun bindComment( + binding: ItemCreatorChannelCommunityCommentBinding, + item: CreatorChannelCommunityCommentUiModel, + onReplyClick: (CreatorChannelCommunityCommentUiModel) -> Unit, + onModifyClick: ((CreatorChannelCommunityCommentUiModel) -> Unit)? = null, + onDeleteClick: ((CreatorChannelCommunityCommentUiModel) -> Unit)? = null + ) = with(binding) { + ivCreatorChannelCommunityCommentProfile.loadUrl(item.memberProfileUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(CircleCropTransformation()) + } + tvCreatorChannelCommunityCommentNickname.text = item.memberNickname + tvCreatorChannelCommunityCommentTime.text = item.createdAtText + tvCreatorChannelCommunityCommentBody.text = item.comment + ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore && onDeleteClick != null + ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor -> + showOptionMenu( + anchor, + item, + onModifyClick ?: {}, + onDeleteClick ?: {} + ) + } + layoutCreatorChannelCommunityCommentLatestReply.isVisible = item.latestReply != null + item.latestReply?.let { latestReply -> + tvCreatorChannelCommunityCommentLatestReplyNickname.text = latestReply.memberNickname + tvCreatorChannelCommunityCommentLatestReplyBody.text = latestReply.comment + } + root.setOnClickListener { onReplyClick(item) } + layoutCreatorChannelCommunityCommentLatestReply.setOnClickListener { onReplyClick(item) } + } + + private fun showOptionMenu( + anchor: View, + item: CreatorChannelCommunityCommentUiModel, + onModifyClick: (CreatorChannelCommunityCommentUiModel) -> Unit, + onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit + ) { + if (!item.canShowMore) return + val popup = PopupMenu(anchor.context, anchor) + val menuRes = if (item.isMine) R.menu.content_comment_option_menu else R.menu.content_comment_option_menu2 + popup.menuInflater.inflate(menuRes, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_review_modify -> onModifyClick(item) + R.id.menu_review_delete -> onDeleteClick(item) + } + true + } + popup.show() + } + + private val CreatorChannelCommunityCommentUiModel.canShowMore: Boolean + get() = isMine || isCreatorOwner + } +}